Merge branch 'main' into fix/native-tool-call-responses-api-shape

This commit is contained in:
Rip&Tear
2026-08-07 17:43:14 +08:00
committed by GitHub
800 changed files with 170716 additions and 156 deletions

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.11",
"crewai-core==1.15.12",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings>=2.14.2,<3",

View File

@@ -1 +1 @@
__version__ = "1.15.11"
__version__ = "1.15.12"

View File

@@ -19,6 +19,7 @@ from crewai_cli.utils import (
enable_prompt_line_editing,
is_dmn_mode_enabled,
read_toml,
warn_deprecated_command,
)
@@ -137,7 +138,10 @@ def uv(uv_args: tuple[str, ...]) -> None:
@crewai.command()
@click.argument(
"type", required=False, default=None, type=click.Choice(["crew", "flow"])
"type",
required=False,
default=None,
type=click.Choice(["crew", "flow", "tool", "skill", "template"]),
)
@click.argument("name", required=False, default=None)
@click.option("--provider", type=str, help="The provider to use for the crew")
@@ -152,6 +156,21 @@ def uv(uv_args: tuple[str, ...]) -> None:
is_flag=True,
help="Create a declarative Flow project instead of a Python Flow project",
)
@click.option(
"--no-project",
"in_project",
is_flag=True,
default=True,
flag_value=False,
help="Skill only: create in current dir instead of ./skills/",
)
@click.option(
"-o",
"--output-dir",
type=str,
default=None,
help="Template only: directory name for the template (defaults to template name)",
)
def create(
type: str | None,
name: str | None,
@@ -159,14 +178,17 @@ def create(
skip_provider: bool = False,
classic: bool = False,
declarative: bool = False,
in_project: bool = True,
output_dir: str | None = None,
) -> None:
"""Create a new crew, or flow."""
"""Create a new crew, flow, tool, skill, or template."""
dmn_mode = is_dmn_mode_enabled()
if not type:
if dmn_mode:
raise click.UsageError(
"TYPE is required when CREWAI_DMN is set. "
"Use `crewai create crew <name>` or `crewai create flow <name>`."
"Use `crewai create <type> <name>` where type is one of: "
"crew, flow, tool, skill, template."
)
from crewai_cli.tui_picker import pick
@@ -176,6 +198,9 @@ def create(
"flow",
"A deterministic workflow with full control over agents and crews",
),
("tool", "A custom tool for the CrewAI Tool Repository"),
("skill", "An agent skill with instructions and optional assets"),
("template", "A remote project template from the CrewAI gallery"),
]
type = pick("What would you like to create?", options)
if type is None:
@@ -189,9 +214,36 @@ def create(
click.style(f" Name of your {type}", fg="cyan", bold=True),
prompt_suffix=click.style(" ", fg="bright_white"), # noqa: RUF001
)
if dmn_mode:
if dmn_mode and type == "crew":
skip_provider = True
if type == "crew":
if not in_project and type != "skill":
raise click.UsageError("--no-project can only be used with skill projects.")
if output_dir is not None and type != "template":
raise click.UsageError("--output-dir can only be used with template projects.")
if type == "tool":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with tool projects."
)
from crewai_cli.tools.main import ToolCommand
ToolCommand().create(name)
elif type == "skill":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with skill projects."
)
from crewai_cli.skills.main import SkillCommand
SkillCommand().create(name, in_project=in_project)
elif type == "template":
if declarative or classic or provider is not None or skip_provider:
raise click.UsageError(
"Crew and flow options cannot be used with template projects."
)
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)
elif type == "crew":
if declarative:
raise click.UsageError("--declarative can only be used with flow projects")
if classic:
@@ -207,7 +259,10 @@ def create(
create_flow(name, declarative=declarative)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
click.secho(
"Error: Invalid type. Must be 'crew', 'flow', 'tool', 'skill', or 'template'.",
fg="red",
)
@crewai.command()
@@ -652,6 +707,8 @@ def tool() -> None:
@tool.command(name="create")
@click.argument("handle")
def tool_create(handle: str) -> None:
"""[Deprecated: use `crewai create tool`] Create a custom tool project."""
warn_deprecated_command(old="crewai tool create", new="crewai create tool")
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
@@ -702,6 +759,8 @@ def skill() -> None:
help="Create skill in current dir instead of ./skills/",
)
def skill_create(name: str, in_project: bool) -> None:
"""[Deprecated: use `crewai create skill`] Create a new agent skill."""
warn_deprecated_command(old="crewai skill create", new="crewai create skill")
from crewai_cli.skills.main import SkillCommand
skill_cmd = SkillCommand()
@@ -765,7 +824,8 @@ def template_list() -> None:
help="Directory name for the template (defaults to template name)",
)
def template_add(name: str, output_dir: str | None) -> None:
"""Add a template to the current directory."""
"""[Deprecated: use `crewai create template`] Add a template to the current directory."""
warn_deprecated_command(old="crewai template add", new="crewai create template")
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)

View File

@@ -40,6 +40,14 @@ This ensures generated code always matches the version actually installed, not s
-`Agent(llm=ChatOpenAI(...))` → ✅ `Agent(llm="openai/gpt-4o")` or `Agent(llm=LLM(model="..."))`
- ❌ Passing raw OpenAI client objects → ✅ Use `crewai.LLM` wrapper
### Deprecated CLI scaffolding aliases (still supported)
These commands remain supported but print a yellow deprecation warning. Prefer the canonical forms:
- ⚠️ `crewai tool create <handle>` → ✅ `crewai create tool <handle>`
- ⚠️ `crewai skill create <name>` → ✅ `crewai create skill <name>`
- ⚠️ `crewai template add <name>` → ✅ `crewai create template <name>`
### How to verify you're using current patterns:
1. You ran the version check and docs lookup steps above before writing code
2. All LLM references use `crewai.LLM` or string shorthand (`"openai/gpt-4o"`)
@@ -137,8 +145,26 @@ uv sync # Sync dependencies
uv lock # Lock dependencies
# Project scaffolding
crewai create crew <name> --skip_provider # New crew project
crewai create flow <name> --skip_provider # New flow project
crewai create crew <name> --skip_provider # New crew project
crewai create flow <name> # New flow project
crewai create tool <handle> # Custom tool repository
crewai create skill <name> # Agent skill (./skills/ in crew projects)
crewai create skill <name> --no-project # Skill in current directory
crewai create template <name> # Remote project template
crewai create template <name> -o <output_dir> # Template with custom output directory
# Deprecated scaffolding aliases (still work; print a yellow warning)
# crewai tool create <handle> → crewai create tool <handle>
# crewai skill create <name> → crewai create skill <name>
# crewai template add <name> → crewai create template <name>
# Tool, skill, and template lifecycle (unchanged)
crewai tool install <handle>
crewai tool publish
crewai skill install @org/name
crewai skill publish
crewai skill list
crewai template list
# Running
crewai run # Run crew or flow (auto-detects from pyproject.toml)
@@ -627,6 +653,26 @@ class MyFlow(Flow):
| `@listen(method)` | Triggers when specified method completes. Receives output as argument |
| `@router(method)` | Conditional branching. Returns string labels that trigger `@listen("label")` |
### `@listen` labels vs handler names
The string in `@listen("...")` is an **event or route label**, not the Python method name. Router return values, route labels, and method completion events share one trigger namespace.
**Never** use the same name for the `@listen` label and the handler method:
```python
# ❌ Wrong — raises a validation error when the flow is instantiated
@listen("create_video")
def create_video(self):
...
# ✅ Correct — distinct handler name (handle_* prefix is a common pattern)
@listen("create_video")
def handle_create_video(self):
...
```
If validation were bypassed, matching names would also cause the handler to re-trigger itself in a loop at runtime. This applies to all flows. It is especially common in **conversational flows** (`conversational = True`), where `@listen("...")` is a router intent name — do not name the handler after the route it serves.
### Structured State
```python
from pydantic import BaseModel
@@ -1113,7 +1159,9 @@ Python >=3.10, <3.14
```bash
uv tool install crewai # Install CrewAI CLI
uv tool list # Verify installation
crewai create crew my_crew --skip_provider # Scaffold a new project
crewai create crew my_crew --skip_provider # Scaffold a crew project
crewai create tool my_tool # Scaffold a tool repository
crewai create skill my_skill # Scaffold an agent skill
crewai install # Install project dependencies
crewai run # Execute
```
@@ -1148,3 +1196,4 @@ crewai run # Execute
- Using `process=Process.hierarchical` without setting `manager_llm` or `manager_agent`
- Circular delegation: set `allow_delegation=False` on specialist agents
- Not installing tools package: `uv add crewai-tools`
- **Matching `@listen("label")` to the handler method name** — raises a validation error at flow instantiation; would re-trigger in an infinite loop at runtime only if validation is bypassed. Use a different method name (e.g. `handle_create_video` for `@listen("create_video")`)

View File

@@ -39,8 +39,8 @@ Pick the simplest action that does the job.
- `state` is the initial shared data shape. Action results do not automatically merge into `state`.
- Read method results with `outputs.method_name` after that method can run.
- `listen` targets a method name or a router-emitted event name.
- Methods must not listen to their own method name.
- Method names and emitted event names share one namespace. Avoid reusing the same string for both unless the user explicitly wants that.
- Methods must not listen to their own method name — including when the `listen` value is a route label that matches the method name (e.g. `listen: create_video` on method `create_video`).
- Method names and emitted event names share one namespace. Do not reuse the same string for a method's `listen` target and its method name.
- Use `router: true` plus `emit` when one method chooses between named branches.
- A router action must return exactly one emitted event string. It must not return JSON, a list, or an explanation.
- Use `start: true` for the single entrypoint.
@@ -107,8 +107,8 @@ Dynamic value rules:
- Do not make `do` a list.
- Do not use CEL `+` to build text in action mappings. Keep the text literal and insert each dynamic value with `${...}`.
- Do not reference `outputs.some_method` before `some_method` can run.
- Do not set a method's `listen` to its own method name.
- Do not use the same string for an emitted event and a method name unless the user asks for it.
- Do not set a method's `listen` to its own method name (including matching route labels such as `listen: create_video` on method `create_video`).
- Do not use the same string for a method's `listen` target and its method name.
- Do not use `emit` without `router: true`.
- Do not rely on crew action-level `inputs` alone to ground agent behavior. Inputs that do not match placeholders are effectively unused by the prompt.
- Do not ask agents to infer missing facts when accuracy matters. Tell them to mark missing dates, amounts, offers, logs, or constraints as unknown.

View File

@@ -44,10 +44,19 @@ __all__ = [
"render_template",
"tree_copy",
"tree_find_and_replace",
"warn_deprecated_command",
"write_env_file",
]
def warn_deprecated_command(*, old: str, new: str) -> None:
"""Print a yellow deprecation warning for a legacy CLI command path."""
click.secho(
f"Warning: The command '{old}' is deprecated. Use '{new}' instead.",
fg="yellow",
)
console = Console()
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")

View File

@@ -228,6 +228,7 @@ def test_create_requires_type_in_dmn_mode(runner):
assert result.exit_code == 2
assert "TYPE is required when CREWAI_DMN is set" in result.output
assert "crew, flow, tool, skill, template" in result.output
def test_create_requires_name_in_dmn_mode(runner):

View File

@@ -0,0 +1,230 @@
"""Tests for unified `crewai create <resource>` scaffolding commands."""
from unittest import mock
import pytest
from click.testing import CliRunner
from crewai_cli.cli import create, crewai
@pytest.fixture
def runner():
return CliRunner()
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_tool_invokes_tool_command(mock_tool_command_cls, runner):
result = runner.invoke(create, ["tool", "my_tool"])
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_tool_create_is_deprecated_and_still_works(mock_tool_command_cls, runner):
result = runner.invoke(crewai, ["tool", "create", "my_tool"])
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
assert (
"Warning: The command 'crewai tool create' is deprecated. "
"Use 'crewai create tool' instead."
in result.output
)
@mock.patch("crewai_cli.tools.main.ToolCommand")
@pytest.mark.parametrize("extra_args", [["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]])
def test_create_tool_rejects_crew_and_flow_flags(mock_tool_command_cls, runner, extra_args):
result = runner.invoke(create, ["tool", "my_tool", *extra_args])
assert result.exit_code == 2, result.output
assert "Crew and flow options cannot be used with tool projects." in result.output
mock_tool_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_invokes_skill_command(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_no_project_flag(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill", "--no-project"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=False
)
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_skill_create_is_deprecated_and_still_works(mock_skill_command_cls, runner):
result = runner.invoke(crewai, ["skill", "create", "my-skill"])
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
assert (
"Warning: The command 'crewai skill create' is deprecated. "
"Use 'crewai create skill' instead."
in result.output
)
@mock.patch("crewai_cli.skills.main.SkillCommand")
@pytest.mark.parametrize(
"extra_args",
[["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]],
)
def test_create_skill_rejects_crew_and_flow_flags(
mock_skill_command_cls, runner, extra_args
):
result = runner.invoke(create, ["skill", "my-skill", *extra_args])
assert result.exit_code == 2, result.output
assert "Crew and flow options cannot be used with skill projects." in result.output
mock_skill_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_crew_rejects_no_project_flag(mock_skill_command_cls, runner):
result = runner.invoke(create, ["crew", "my-crew", "--no-project"])
assert result.exit_code == 2, result.output
assert "--no-project can only be used with skill projects." in result.output
mock_skill_command_cls.return_value.create.assert_not_called()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_template_invokes_template_command(mock_template_command_cls, runner):
result = runner.invoke(create, ["template", "my-template"])
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", None
)
assert "deprecated" not in result.output.lower()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_template_output_dir_flag(mock_template_command_cls, runner):
result = runner.invoke(
create, ["template", "my-template", "--output-dir", "custom_dir"]
)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", "custom_dir"
)
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_template_add_is_deprecated_and_still_works(mock_template_command_cls, runner):
result = runner.invoke(
crewai, ["template", "add", "my-template", "--output-dir", "custom_dir"]
)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", "custom_dir"
)
assert (
"Warning: The command 'crewai template add' is deprecated. "
"Use 'crewai create template' instead."
in result.output
)
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
@pytest.mark.parametrize(
"extra_args",
[["--classic"], ["--declarative"], ["--provider", "openai"], ["--skip_provider"]],
)
def test_create_template_rejects_crew_and_flow_flags(
mock_template_command_cls, runner, extra_args
):
result = runner.invoke(create, ["template", "my-template", *extra_args])
assert result.exit_code == 2, result.output
assert (
"Crew and flow options cannot be used with template projects."
in result.output
)
mock_template_command_cls.return_value.add_template.assert_not_called()
@mock.patch("crewai_cli.remote_template.main.TemplateCommand")
def test_create_crew_rejects_output_dir_flag(mock_template_command_cls, runner):
result = runner.invoke(create, ["crew", "my-crew", "--output-dir", "custom_dir"])
assert result.exit_code == 2, result.output
assert "--output-dir can only be used with template projects." in result.output
mock_template_command_cls.return_value.add_template.assert_not_called()
@mock.patch("crewai_cli.cli.enable_prompt_line_editing")
@mock.patch("crewai_cli.cli.click.prompt", return_value="picked-tool")
@mock.patch("crewai_cli.tui_picker.pick", return_value="tool")
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_picker_supports_tool_skill_and_template(
mock_tool_command_cls,
mock_pick,
mock_prompt,
mock_enable_prompt,
runner,
):
result = runner.invoke(create, [])
assert result.exit_code == 0, result.output
mock_pick.assert_called_once()
picker_options = mock_pick.call_args[0][1]
assert {option[0] for option in picker_options} == {
"crew",
"flow",
"tool",
"skill",
"template",
}
mock_prompt.assert_called_once()
mock_tool_command_cls.return_value.create.assert_called_once_with("picked-tool")
_DMN_ENV = {"CREWAI_DMN": "True"}
@mock.patch("crewai_cli.tools.main.ToolCommand")
def test_create_tool_works_in_dmn_mode(mock_tool_command_cls, runner):
result = runner.invoke(create, ["tool", "my_tool"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_tool_command_cls.return_value.create.assert_called_once_with("my_tool")
@mock.patch("crewai_cli.skills.main.SkillCommand")
def test_create_skill_works_in_dmn_mode(mock_skill_command_cls, runner):
result = runner.invoke(create, ["skill", "my-skill"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_skill_command_cls.return_value.create.assert_called_once_with(
"my-skill", in_project=True
)
@mock.patch("crewai_cli.cli.TemplateCommand")
def test_create_template_works_in_dmn_mode(mock_template_command_cls, runner):
result = runner.invoke(create, ["template", "my-template"], env=_DMN_ENV)
assert result.exit_code == 0, result.output
mock_template_command_cls.return_value.add_template.assert_called_once_with(
"my-template", None
)

View File

@@ -1 +1 @@
__version__ = "1.15.11"
__version__ = "1.15.12"

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.15.11"
__version__ = "1.15.12"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.15.11",
"crewai==1.15.12",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -209,6 +209,7 @@ from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
@@ -327,6 +328,7 @@ __all__ = [
"TavilyGetResearchTool",
"TavilyResearchTool",
"TavilySearchTool",
"URLReadTool",
"VisionTool",
"WaitTool",
"WeaviateVectorSearchTool",
@@ -338,4 +340,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.15.11"
__version__ = "1.15.12"

View File

@@ -2,13 +2,17 @@
import os
from pathlib import Path
import tempfile
from typing import Any
from urllib.parse import urlparse
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
from crewai_tools.rag.source_content import SourceContent
from crewai_tools.security.safe_requests import safe_get
from crewai_tools.security.safe_requests import safe_get_bounded
# Remote PDFs are held in memory for the whole extraction, so the download needs
# a ceiling. Override per call with the ``max_bytes`` kwarg to ``load``.
DEFAULT_MAX_PDF_BYTES = 50 * 1024 * 1024
class PDFLoader(BaseLoader):
@@ -24,18 +28,24 @@ class PDFLoader(BaseLoader):
return False
@staticmethod
def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
"""Download PDF from a URL to a temporary file and return its path.
def _fetch_from_url(url: str, kwargs: dict[str, Any]) -> bytes:
"""Download a PDF from a URL and return its bytes.
The content stays in memory rather than going to a temporary file: the
whole body has to be buffered either way, and a temp file would need
unlinking on every error path to avoid leaving files behind. Because it
is held in memory, the download is capped.
Args:
url: The URL to download from.
kwargs: Optional dict that may contain custom headers.
kwargs: Optional dict that may contain custom ``headers`` and a
``max_bytes`` ceiling for the download.
Returns:
Path to the temporary file containing the PDF.
The raw PDF content.
Raises:
ValueError: If the download fails.
ValueError: If the download fails or exceeds the size ceiling.
"""
headers = kwargs.get(
"headers",
@@ -46,12 +56,13 @@ class PDFLoader(BaseLoader):
)
try:
response = safe_get(url, headers=headers, timeout=30)
response.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
temp_file.write(response.content)
return temp_file.name
body, _content_type, _final_url = safe_get_bounded(
url,
max_bytes=kwargs.get("max_bytes", DEFAULT_MAX_PDF_BYTES),
headers=headers,
timeout=30,
)
return body
except Exception as e:
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
@@ -93,21 +104,25 @@ class PDFLoader(BaseLoader):
try:
if is_url:
local_path = self._download_from_url(file_path, kwargs)
doc = pymupdf.open(local_path)
doc = pymupdf.open(
stream=self._fetch_from_url(file_path, kwargs), filetype="pdf"
)
else:
if not os.path.isfile(file_path):
raise FileNotFoundError(f"PDF file not found: {file_path}")
doc = pymupdf.open(file_path)
metadata["num_pages"] = len(doc)
# Closed in a finally so a failure mid-extraction still releases the
# document handle.
try:
metadata["num_pages"] = len(doc)
for page_num, page in enumerate(doc, 1):
page_text = page.get_text()
if page_text.strip():
text_content.append(f"Page {page_num}:\n{page_text}")
doc.close()
for page_num, page in enumerate(doc, 1):
page_text = page.get_text()
if page_text.strip():
text_content.append(f"Page {page_num}:\n{page_text}")
finally:
doc.close()
except FileNotFoundError:
raise
except Exception as e:

View File

@@ -11,6 +11,7 @@ from crewai_tools.security.safe_path import validate_url
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_STREAM_CHUNK_SIZE = 65536
_SENSITIVE_HEADER_NAMES = {
"authorization",
"cookie",
@@ -49,40 +50,124 @@ def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str,
def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response:
"""GET a URL while validating each redirect target before following it."""
"""GET a URL while validating each redirect target before following it.
On success the hops are attached to the returned response's ``history`` and
are the caller's to close. On failure they are closed here: a caller given
an exception has no handle on them, and a streamed hop holds its connection
until its body is read or closed.
"""
current_url = validate_url(url)
request_kwargs = {**kwargs, "allow_redirects": False}
timeout = request_kwargs.pop("timeout", 30)
history: list[requests.Response] = []
redirects_followed = 0
while True:
response = requests.get(current_url, timeout=timeout, **request_kwargs)
if (
response.status_code not in _REDIRECT_STATUS_CODES
or "Location" not in response.headers
):
response.history = history
return response
try:
while True:
response = requests.get(current_url, timeout=timeout, **request_kwargs)
if (
response.status_code not in _REDIRECT_STATUS_CODES
or "Location" not in response.headers
):
response.history = history
return response
if redirects_followed >= max_redirects:
response.close()
raise ValueError(f"Too many redirects while fetching URL: {url}")
if redirects_followed >= max_redirects:
response.close()
raise ValueError(f"Too many redirects while fetching URL: {url}")
location = response.headers.get("Location")
if not location:
response.history = history
return response
location = response.headers.get("Location")
if not location:
response.history = history
return response
try:
redirect_url = validate_url(urljoin(response.url, location))
except ValueError:
response.close()
raise
try:
redirect_url = validate_url(urljoin(response.url, location))
except ValueError:
response.close()
raise
if not _same_origin(current_url, redirect_url):
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
if not _same_origin(current_url, redirect_url):
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
history.append(response)
current_url = redirect_url
redirects_followed += 1
history.append(response)
current_url = redirect_url
redirects_followed += 1
except BaseException:
for hop in history:
hop.close()
raise
def safe_get_bounded(
url: str,
*,
max_bytes: int,
timeout: float | tuple[float, float] = 30,
headers: dict[str, str] | None = None,
max_redirects: int = 10,
) -> tuple[bytes, str, str]:
"""GET a URL through :func:`safe_get`, refusing bodies over *max_bytes*.
The body is streamed and abandoned as soon as it crosses the limit, so an
oversized response costs one chunk of memory instead of all of it. The cap
counts decoded bytes, which is what a compressed response expands into --
``Content-Length`` describes the wire size and cannot bound that.
Args:
url: The URL to fetch.
max_bytes: Largest body to accept, in decoded bytes.
timeout: Request timeout, passed through to requests.
headers: Request headers.
max_redirects: Hops to follow before giving up.
Returns:
A ``(body, content_type, final_url)`` tuple, where *final_url* is the
last validated URL in the redirect chain.
Raises:
ValueError: If *max_bytes* is not positive, URL validation fails, the
redirect chain is too long, or the body exceeds *max_bytes*.
requests.RequestException: If the request fails or returns an error
status.
"""
if max_bytes <= 0:
raise ValueError(f"max_bytes must be positive, got {max_bytes}.")
response = safe_get(
url,
max_redirects=max_redirects,
headers=headers,
timeout=timeout,
stream=True,
)
try:
response.raise_for_status()
chunks: list[bytes] = []
total = 0
for chunk in response.iter_content(chunk_size=_STREAM_CHUNK_SIZE):
if not chunk:
continue
total += len(chunk)
if total > max_bytes:
# Names the URL that served the body, which after a redirect is
# not the one that was requested.
raise ValueError(
f"Response body from '{response.url}' exceeds the "
f"{max_bytes} byte limit."
)
chunks.append(chunk)
return (
b"".join(chunks),
response.headers.get("Content-Type", ""),
response.url,
)
finally:
# Under stream=True each hop holds its connection until the body is read,
# so the redirects need closing too, not just the response we return.
for hop in response.history:
hop.close()
response.close()

View File

@@ -196,6 +196,7 @@ from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
@@ -310,6 +311,7 @@ __all__ = [
"TavilyGetResearchTool",
"TavilyResearchTool",
"TavilySearchTool",
"URLReadTool",
"VisionTool",
"WaitTool",
"WeaviateVectorSearchTool",

View File

@@ -17,6 +17,7 @@ from crewai_tools.tools.crewai_platform_tools.misc import (
class CrewAIPlatformActionTool(BaseTool):
app: str = Field(description="The integration slug for this action")
action_name: str = Field(default="", description="The name of the action")
action_schema: dict[str, Any] = Field(
default_factory=dict, description="The schema of the action"
@@ -25,6 +26,7 @@ class CrewAIPlatformActionTool(BaseTool):
def __init__(
self,
description: str,
app: str,
action_name: str,
action_schema: dict[str, Any],
):
@@ -46,6 +48,7 @@ class CrewAIPlatformActionTool(BaseTool):
name=action_name.lower().replace(" ", "_"),
description=description,
args_schema=args_schema,
app=app,
)
self.action_name = action_name
self.action_schema = action_schema

View File

@@ -89,6 +89,7 @@ class CrewaiPlatformToolBuilder:
tool = CrewAIPlatformActionTool(
description=description,
app=function_details["app"],
action_name=action_name,
action_schema=action_schema,
)

View File

@@ -0,0 +1,4 @@
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
__all__ = ["URLReadTool"]

View File

@@ -0,0 +1,379 @@
"""Tool for reading the content at an arbitrary URL as text."""
from __future__ import annotations
from io import BytesIO
from itertools import islice
import re
from typing import Any, Final
from urllib.parse import urlparse
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import format_error_for_display
from crewai_tools.security.safe_requests import safe_get_bounded
_DEFAULT_MAX_BYTES: Final[int] = 5 * 1024 * 1024
_DEFAULT_TIMEOUT: Final[int] = 30
_PDF_TYPE: Final[str] = "application/pdf"
_DOCX_TYPE: Final[str] = (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
_HTML_TYPES: Final[frozenset[str]] = frozenset({"text/html", "application/xhtml+xml"})
_TEXT_TYPES: Final[frozenset[str]] = frozenset(
{
"application/csv",
"application/javascript",
"application/json",
"application/sql",
"application/x-ndjson",
"application/x-yaml",
"application/xml",
"application/yaml",
}
)
_TEXT_TYPE_SUFFIXES: Final[tuple[str, ...]] = ("+json", "+xml", "+yaml")
# Servers commonly serve static files as octet-stream, or send no type at all,
# so the extension is consulted when the header carries no usable answer.
_UNINFORMATIVE_TYPES: Final[frozenset[str]] = frozenset(
{"", "application/octet-stream", "binary/octet-stream"}
)
_EXTENSION_TYPES: Final[dict[str, str]] = {
".csv": "text/csv",
".docx": _DOCX_TYPE,
".htm": "text/html",
".html": "text/html",
".json": "application/json",
".md": "text/markdown",
".pdf": _PDF_TYPE,
".txt": "text/plain",
".xml": "application/xml",
".yaml": "application/yaml",
".yml": "application/yaml",
}
_SPACES_PATTERN: Final[re.Pattern[str]] = re.compile(r"[ \t]+")
_NEWLINE_PATTERN: Final[re.Pattern[str]] = re.compile(r"\s+\n\s+")
def _charset_from_content_type(content_type: str) -> str | None:
"""Return the charset parameter of a Content-Type header, if it has one."""
for parameter in content_type.split(";")[1:]:
name, _, value = parameter.partition("=")
if name.strip().lower() == "charset":
return value.strip().strip('"') or None
return None
class URLReadToolSchema(BaseModel):
"""Input for URLReadTool."""
url: str = Field(
...,
description=(
"The http:// or https:// URL to read. Addresses that resolve to "
"private or internal networks are refused."
),
)
start_line: int | None = Field(
1, ge=1, description="Line number to start reading from (1-indexed)"
)
line_count: int | None = Field(
None,
ge=1,
description="Number of lines to read. If None, reads the entire content",
)
class URLReadTool(BaseTool):
"""Read the content at an arbitrary URL and return it as text.
Unlike :class:`~crewai_tools.tools.file_read_tool.file_read_tool.FileReadTool`,
which is confined to the local filesystem, this tool performs network
requests to addresses the caller -- often an LLM -- chooses at runtime. It
is a separate tool for exactly that reason: granting it is granting network
egress, and that should be a deliberate choice rather than a flag on a
filesystem tool.
Responses are decoded to text according to their content type. PDF and DOCX
bodies have their text extracted, HTML is stripped to visible text, and
text-shaped types (plain text, Markdown, JSON, XML, YAML, CSV) are decoded
as-is. Any other type is refused rather than returned as base64, keeping
this tool's output text-only.
Security:
Requests go through :func:`~crewai_tools.security.safe_requests.safe_get_bounded`,
which resolves each hostname and rejects it when any resolved address is
private, loopback, link-local, or otherwise reserved -- covering cloud
metadata endpoints and internal services. Redirects are never followed
automatically: every hop is revalidated, and credentials are dropped on
cross-origin hops. Bodies over ``max_bytes`` are abandoned mid-stream.
Two risks are not closed here. Validation resolves the hostname and
requests resolves it again when connecting, so a DNS entry that changes
between those lookups can still redirect the connection (DNS
rebinding); closing that requires pinning the connection to the
validated address. And the returned text is untrusted remote content
flowing into an agent's context -- a fetched page can attempt to
instruct the agent. Neither is addressable by input validation alone;
network egress policy and prompt-level handling cover them.
Args:
max_bytes (int): Largest response body to accept, in decoded bytes.
Defaults to 5 MiB.
timeout (float): Per-request timeout in seconds. Defaults to 30.
headers (Optional[dict[str, str]]): Extra request headers. Developer
supplied, not chosen by the model.
encoding (Optional[str]): Force a text encoding instead of honoring the
charset the server declares.
**kwargs: Additional keyword arguments passed to BaseTool.
Example:
>>> tool = URLReadTool()
>>> content = tool.run(url="https://example.com/report.pdf")
>>> head = tool.run(url="https://example.com/data.csv", line_count=20)
"""
name: str = "Read content from a URL"
description: str = (
"A tool that reads the content at a URL and returns it as text. To use "
"this tool, provide a 'url' parameter with an http:// or https:// "
"address. PDF, DOCX, HTML, JSON, XML, CSV and plain-text responses are "
"converted to text; other binary types are rejected. URLs that resolve "
"to private or internal network addresses are refused, as are responses "
"over the tool's size limit. Optionally provide 'start_line' and "
"'line_count' to read only part of the content."
)
args_schema: type[BaseModel] = URLReadToolSchema
max_bytes: int = _DEFAULT_MAX_BYTES
timeout: float = _DEFAULT_TIMEOUT
headers: dict[str, str] | None = None
encoding: str | None = None
def __init__(
self,
max_bytes: int = _DEFAULT_MAX_BYTES,
timeout: float = _DEFAULT_TIMEOUT,
headers: dict[str, str] | None = None,
encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize the URLReadTool.
Args:
max_bytes: Largest response body to accept, in decoded bytes.
timeout: Per-request timeout in seconds.
headers: Extra request headers.
encoding: Force a text encoding instead of the server's charset.
**kwargs: Additional keyword arguments passed to BaseTool.
"""
super().__init__(**kwargs)
self.max_bytes = max_bytes
self.timeout = timeout
self.headers = headers
self.encoding = encoding
def _request_headers(self) -> dict[str, str]:
"""Return the headers to send, with caller headers taking precedence."""
return {
"Accept": "*/*",
"User-Agent": "Mozilla/5.0 (compatible; crewai-tools URLReadTool)",
**(self.headers or {}),
}
@staticmethod
def _classify(media_type: str) -> str | None:
"""Map a media type onto the extractor that handles it."""
if media_type == _PDF_TYPE:
return "pdf"
if media_type == _DOCX_TYPE:
return "docx"
if media_type in _HTML_TYPES:
return "html"
if (
media_type.startswith("text/")
or media_type in _TEXT_TYPES
or media_type.endswith(_TEXT_TYPE_SUFFIXES)
):
return "text"
return None
def _resolve_kind(self, content_type: str, *urls: str) -> str | None:
"""Decide how to extract text, by content type then by URL extension.
Args:
content_type: The raw Content-Type header value.
*urls: URLs to consult for an extension, most authoritative first.
A ``.pdf`` link that redirects to an extensionless CDN or
presigned path only carries its type on the requested URL, so
both ends of the chain are worth checking.
Returns:
The extractor name, or None when the content type is unsupported.
"""
declared = content_type.split(";", 1)[0].strip().lower()
if declared not in _UNINFORMATIVE_TYPES:
return self._classify(declared)
for url in urls:
path = urlparse(url).path.lower()
for extension, media_type in _EXTENSION_TYPES.items():
if path.endswith(extension):
return self._classify(media_type)
return None
def _decode(self, body: bytes, content_type: str) -> str:
"""Decode *body* using the configured, declared, or default encoding.
Falls back to a replacing UTF-8 decode rather than failing: partially
readable text is more useful to an agent than an error.
"""
encoding = self.encoding or _charset_from_content_type(content_type) or "utf-8"
try:
return body.decode(encoding)
except (LookupError, UnicodeDecodeError):
return body.decode("utf-8", errors="replace")
@staticmethod
def _extract_pdf(body: bytes) -> str:
"""Extract text from PDF bytes, page by page."""
try:
import pymupdf # type: ignore[import-untyped]
except ImportError as e:
raise ImportError(
"Reading PDF URLs requires pymupdf. Install with: uv add pymupdf"
) from e
# Opened from memory: the bytes are already in hand, and a temp file
# would need cleaning up on every error path.
document = pymupdf.open(stream=body, filetype="pdf")
try:
pages = [
f"Page {number}:\n{text}"
for number, page in enumerate(document, 1)
if (text := page.get_text().strip())
]
finally:
document.close()
if not pages:
return "[PDF with no extractable text]"
return "\n\n".join(pages)
@staticmethod
def _extract_docx(body: bytes) -> str:
"""Extract paragraph text from DOCX bytes."""
try:
from docx import Document
except ImportError as e:
raise ImportError(
"Reading DOCX URLs requires python-docx. Install with: "
"uv add python-docx"
) from e
document = Document(BytesIO(body))
return "\n".join(
paragraph.text
for paragraph in document.paragraphs
if paragraph.text.strip()
)
def _extract_html(self, body: bytes, content_type: str) -> str:
"""Strip HTML bytes down to visible text."""
try:
from bs4 import BeautifulSoup
except ImportError as e:
raise ImportError(
"Reading HTML URLs requires beautifulsoup4. Install with: "
"uv add beautifulsoup4"
) from e
soup = BeautifulSoup(self._decode(body, content_type), "html.parser")
for element in soup(["script", "style"]):
element.decompose()
text = _SPACES_PATTERN.sub(" ", soup.get_text(" "))
return _NEWLINE_PATTERN.sub("\n", text).strip()
def _extract(self, body: bytes, kind: str, content_type: str) -> str:
"""Dispatch to the extractor named by *kind*."""
if kind == "pdf":
return self._extract_pdf(body)
if kind == "docx":
return self._extract_docx(body)
if kind == "html":
return self._extract_html(body, content_type)
return self._decode(body, content_type)
@staticmethod
def _window(text: str, start_line: int, line_count: int | None) -> str:
"""Return the requested line window of *text*.
The whole body has already been fetched by this point, so unlike the
filesystem equivalent this only trims output -- it saves no transfer.
The bounds are clamped rather than trusted: the args schema rejects
anything below 1 before it gets here, but islice raises on a negative
stop index, and this runs outside the caller's error handling.
"""
if start_line == 1 and line_count is None:
return text
start_index = max(start_line - 1, 0)
stop_index = None if line_count is None else start_index + max(line_count, 0)
selected = list(islice(text.splitlines(keepends=True), start_index, stop_index))
if not selected and start_index > 0:
return (
f"Error: Start line {start_line} exceeds the number of lines in "
f"the content."
)
return "".join(selected)
def _run(
self,
url: str,
start_line: int | None = 1,
line_count: int | None = None,
) -> str:
"""Fetch a URL and return its content, or a window of it, as text."""
start_line = start_line or 1
line_count = line_count or None
try:
body, content_type, final_url = safe_get_bounded(
url,
max_bytes=self.max_bytes,
timeout=self.timeout,
headers=self._request_headers(),
)
except ValueError as e:
return f"Error: {e}"
except requests.RequestException as e:
return f"Error: Failed to fetch '{url}'. {format_error_for_display(e)}"
kind = self._resolve_kind(content_type, final_url, url)
if kind is None:
return (
f"Error: Unsupported content type "
f"'{content_type.split(';', 1)[0].strip() or 'unknown'}' at "
f"'{url}'. This tool reads text, HTML, JSON, XML, CSV, PDF and "
f"DOCX responses."
)
try:
text = self._extract(body, kind, content_type)
except ImportError as e:
return f"Error: {e}"
except Exception as e:
return (
f"Error: Failed to read {kind.upper()} content from '{url}'. "
f"{format_error_for_display(e)}"
)
return self._window(text, start_line, line_count)

View File

@@ -0,0 +1,156 @@
import tempfile
from unittest.mock import patch
from crewai_tools.rag.base_loader import LoaderResult
from crewai_tools.rag.loaders.pdf_loader import PDFLoader
from crewai_tools.rag.source_content import SourceContent
import pytest
pymupdf = pytest.importorskip("pymupdf")
# Patched at the loader's seam rather than at requests.get: safe_get_bounded
# resolves the hostname before issuing a request, which would make these tests
# depend on DNS for example.com.
FETCH = "crewai_tools.rag.loaders.pdf_loader.safe_get_bounded"
def build_pdf(text: str = "Quarterly revenue was 42") -> bytes:
"""Return the bytes of a one-page PDF containing *text*."""
document = pymupdf.open()
document.new_page().insert_text((72, 72), text)
try:
return document.tobytes()
finally:
document.close()
def fetch_result(body: bytes, url: str = "https://example.com/report.pdf"):
"""Build the (body, content_type, final_url) tuple safe_get_bounded returns."""
return body, "application/pdf", url
class TestPDFLoader:
def test_load_pdf_from_file(self):
"""A PDF on disk has its text extracted with page markers."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(build_pdf())
f.flush()
result = PDFLoader().load(SourceContent(f.name))
assert isinstance(result, LoaderResult)
assert "Page 1:" in result.content
assert "Quarterly revenue was 42" in result.content
assert result.metadata["num_pages"] == 1
assert result.metadata["file_type"] == "pdf"
def test_load_pdf_from_url(self):
"""A PDF fetched from a URL is extracted and attributed to that URL."""
with patch(FETCH) as fetch:
fetch.return_value = fetch_result(build_pdf("Content from URL"))
result = PDFLoader().load(SourceContent("https://example.com/report.pdf"))
assert "Content from URL" in result.content
assert result.source == "https://example.com/report.pdf"
assert result.metadata["file_name"] == "report.pdf"
headers = fetch.call_args.kwargs["headers"]
assert headers["Accept"] == "application/pdf"
assert "crewai-tools PDFLoader" in headers["User-Agent"]
def test_load_pdf_from_url_leaves_no_temp_file(self):
"""The URL path must not write a temp file it never cleans up.
It previously used NamedTemporaryFile(delete=False) without unlinking,
so every PDF ingested from a URL left a file behind.
"""
with (
patch(FETCH) as fetch,
patch("tempfile.NamedTemporaryFile") as mock_tempfile,
):
fetch.return_value = fetch_result(build_pdf())
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
mock_tempfile.assert_not_called()
def test_load_pdf_from_url_is_size_bounded(self):
"""The download is capped, since the body is held in memory."""
with patch(FETCH) as fetch:
fetch.return_value = fetch_result(build_pdf())
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
assert fetch.call_args.kwargs["max_bytes"] == 50 * 1024 * 1024
def test_load_pdf_from_url_accepts_a_custom_size_limit(self):
"""Callers can lower or raise the ceiling per load."""
with patch(FETCH) as fetch:
fetch.return_value = fetch_result(build_pdf())
PDFLoader().load(
SourceContent("https://example.com/report.pdf"), max_bytes=1024
)
assert fetch.call_args.kwargs["max_bytes"] == 1024
def test_load_pdf_from_url_with_custom_headers(self):
"""Caller-supplied headers replace the loader's defaults."""
custom_headers = {"Authorization": "Bearer token"}
with patch(FETCH) as fetch:
fetch.return_value = fetch_result(build_pdf())
PDFLoader().load(
SourceContent("https://example.com/report.pdf"), headers=custom_headers
)
assert fetch.call_args.kwargs["headers"] == custom_headers
def test_load_pdf_url_download_error(self):
"""A failed download surfaces as a ValueError naming the URL."""
with patch(FETCH, side_effect=Exception("Network error")):
with pytest.raises(ValueError, match="Failed to download PDF"):
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
def test_load_pdf_url_over_size_limit(self):
"""An oversized body is reported rather than partially parsed."""
with patch(FETCH, side_effect=ValueError("exceeds the 1024 byte limit")):
with pytest.raises(ValueError, match="Failed to download PDF"):
PDFLoader().load(SourceContent("https://example.com/huge.pdf"))
def test_load_pdf_missing_file(self):
"""A missing local path raises FileNotFoundError, not ValueError."""
with pytest.raises(FileNotFoundError, match="PDF file not found"):
PDFLoader().load(SourceContent("/nonexistent/report.pdf"))
def test_load_corrupt_pdf_raises_value_error(self):
"""Bytes that are not a parseable PDF produce a read error."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(b"%PDF-1.4 not really a pdf")
f.flush()
with pytest.raises(ValueError, match="Error reading PDF"):
PDFLoader().load(SourceContent(f.name))
def test_pdf_with_no_extractable_text(self):
"""A PDF whose pages hold no text says so instead of returning empty."""
document = pymupdf.open()
document.new_page()
blank = document.tobytes()
document.close()
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(blank)
f.flush()
result = PDFLoader().load(SourceContent(f.name))
assert "no extractable text" in result.content
def test_pdf_doc_id_is_stable(self):
"""The same source yields the same doc_id across loads."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(build_pdf())
f.flush()
loader = PDFLoader()
source = SourceContent(f.name)
assert loader.load(source).doc_id == loader.load(source).doc_id

View File

@@ -28,6 +28,7 @@ class TestCrewAIPlatformActionToolVerify:
def create_test_tool(self):
return CrewAIPlatformActionTool(
description="Test action tool",
app="test_app",
action_name="test_action",
action_schema=self.action_schema
)

View File

@@ -171,6 +171,10 @@ class TestCrewaiPlatformToolBuilder(unittest.TestCase):
tool_names = [tool.action_name for tool in tools]
assert "create_issue" in tool_names
assert "send_message" in tool_names
assert {tool.action_name: tool.app for tool in tools} == {
"create_issue": "github",
"send_message": "slack",
}
github_tool = next((t for t in tools if t.action_name == "create_issue"), None)
slack_tool = next((t for t in tools if t.action_name == "send_message"), None)

View File

@@ -0,0 +1,415 @@
from unittest.mock import patch
import pytest
import requests
from crewai_tools import URLReadTool
from crewai_tools.security.safe_requests import safe_get_bounded
TOOL_MODULE = "crewai_tools.tools.url_read_tool.url_read_tool"
class FakeResponse:
"""Minimal stand-in for a streamed requests.Response."""
def __init__(
self,
body: bytes = b"",
content_type: str = "text/plain",
url: str = "https://example.com/file.txt",
status_code: int = 200,
chunk_size: int | None = None,
):
self._body = body
self._chunk_size = chunk_size
self.headers = {"Content-Type": content_type} if content_type else {}
self.url = url
self.status_code = status_code
self.history: list["FakeResponse"] = []
self.closed = False
def raise_for_status(self) -> None:
"""Mimic requests' error-status behavior."""
if self.status_code >= 400:
raise requests.HTTPError(f"{self.status_code} error")
def iter_content(self, chunk_size: int = 65536):
"""Yield the body in chunks, like a streamed response."""
size = self._chunk_size or chunk_size
for index in range(0, len(self._body), size):
yield self._body[index : index + size]
def close(self) -> None:
"""Record that the response was closed."""
self.closed = True
def build_pdf(text: str = "Quarterly revenue was 42") -> bytes:
"""Return the bytes of a one-page PDF containing *text*."""
pymupdf = pytest.importorskip("pymupdf")
document = pymupdf.open()
document.new_page().insert_text((72, 72), text)
try:
return document.tobytes()
finally:
document.close()
def fetch_result(
body: bytes,
content_type: str = "text/plain",
url: str = "https://example.com/f.txt",
):
"""Build the (body, content_type, final_url) tuple safe_get_bounded returns."""
return body, content_type, url
def test_reads_plain_text():
"""A text response is returned as-is, with the configured limits applied."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"hello world")
assert tool.run(url="https://example.com/f.txt") == "hello world"
assert fetch.call_args.kwargs["max_bytes"] == 5 * 1024 * 1024
assert fetch.call_args.kwargs["timeout"] == 30
def test_honors_declared_charset():
"""The charset in the Content-Type header drives decoding."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
"café".encode("latin-1"), "text/plain; charset=iso-8859-1"
)
assert tool.run(url="https://example.com/f.txt") == "café"
def test_encoding_override_wins_over_server_charset():
"""An explicit encoding beats whatever the server declares."""
tool = URLReadTool(encoding="latin-1")
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
"café".encode("latin-1"), "text/plain; charset=utf-8"
)
assert tool.run(url="https://example.com/f.txt") == "café"
def test_undecodable_bytes_fall_back_instead_of_failing():
"""Partially readable text beats an error for the agent."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"\xff\xfe bad bytes", "text/plain")
result = tool.run(url="https://example.com/f.txt")
assert "bad bytes" in result
assert not result.startswith("Error:")
def test_line_window():
"""start_line and line_count select a window of the extracted text."""
tool = URLReadTool()
body = b"one\ntwo\nthree\nfour\nfive\n"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(body)
result = tool.run(url="https://example.com/f.txt", start_line=2, line_count=2)
assert result == "two\nthree\n"
def test_start_line_past_end_reports_error():
"""Asking past the end of the content is reported, not silently empty."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"one\ntwo\n")
result = tool.run(url="https://example.com/f.txt", start_line=99)
assert "exceeds the number of lines" in result
@pytest.mark.parametrize(
"line_args",
[{"line_count": -5}, {"line_count": 0}, {"start_line": 0}, {"start_line": -5}],
)
def test_line_arguments_below_one_are_refused(line_args):
"""Out-of-range line arguments are rejected before any request is made.
islice raises on a negative stop index, and the windowing runs outside the
tool's error handling, so these have to be refused at validation time.
"""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
with pytest.raises(ValueError, match="greater than or equal to 1"):
tool.run(url="https://example.com/f.txt", **line_args)
fetch.assert_not_called()
def test_json_is_returned_verbatim():
"""JSON is passed through undecorated so callers can parse it."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b'{"a": 1}', "application/json")
assert tool.run(url="https://example.com/data.json") == '{"a": 1}'
def test_structured_suffix_type_is_treated_as_text():
"""A +json vendor type is text, not an unsupported binary type."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b'{"a": 1}', "application/vnd.api+json")
assert tool.run(url="https://example.com/data") == '{"a": 1}'
def test_html_is_stripped_to_visible_text():
"""HTML returns visible text with script and style content removed."""
tool = URLReadTool()
body = b"<html><head><style>p{color:red}</style></head><body><p>Hi</p><script>x=1</script></body></html>"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(body, "text/html; charset=utf-8")
result = tool.run(url="https://example.com/page")
assert "Hi" in result
assert "x=1" not in result
assert "color:red" not in result
def test_binary_content_type_is_rejected():
"""An unsupported type is refused rather than returned as base64."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"\x89PNG\r\n", "image/png")
result = tool.run(url="https://example.com/logo.png")
assert "Unsupported content type 'image/png'" in result
def test_octet_stream_pdf_falls_back_to_url_extension():
"""A PDF served as octet-stream is still extracted, via its extension."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
build_pdf("Fallback worked"),
"application/octet-stream",
"https://example.com/a/b.pdf",
)
result = tool.run(url="https://example.com/a/b.pdf")
assert "Fallback worked" in result
def test_missing_content_type_falls_back_to_url_extension():
"""No Content-Type at all still reads as text when the path says .csv."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"a,b\n1,2\n", "", "https://example.com/b.csv")
assert tool.run(url="https://example.com/b.csv") == "a,b\n1,2\n"
def test_query_string_does_not_break_extension_fallback():
"""A presigned-style query string does not hide the path's extension."""
tool = URLReadTool()
url = "https://example.com/b.csv?X-Amz-Signature=abc"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"a,b\n", "application/octet-stream", url)
assert tool.run(url=url) == "a,b\n"
def test_extension_from_requested_url_survives_a_redirect():
"""A .pdf link that redirects to an extensionless path is still extracted.
Presigned CDN targets routinely drop the extension and serve octet-stream,
so the requested URL is the only place the type survives.
"""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
build_pdf("Survived the redirect"),
"application/octet-stream",
"https://cdn.example.com/objects/9f8a7b6c5d",
)
result = tool.run(url="https://example.com/report.pdf")
assert "Survived the redirect" in result
def test_octet_stream_with_unknown_extension_is_rejected():
"""With neither a usable type nor a known extension, the read is refused."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
b"\x00\x01", "application/octet-stream", "https://example.com/a/b.bin"
)
result = tool.run(url="https://example.com/a/b.bin")
assert "Unsupported content type" in result
def test_validation_failure_is_returned_as_error():
"""An SSRF rejection reaches the agent as an error string, not an exception."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.side_effect = ValueError(
"URL 'http://169.254.169.254/' resolves to private/reserved IP 169.254.169.254."
)
result = tool.run(url="http://169.254.169.254/")
assert result.startswith("Error:")
assert "private/reserved IP" in result
def test_request_failure_is_returned_as_error():
"""A transport failure is reported without raising out of the tool."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.side_effect = requests.ConnectionError("connection refused")
result = tool.run(url="https://example.com/f.txt")
assert result.startswith("Error: Failed to fetch")
def test_custom_headers_are_merged_over_defaults():
"""Caller headers win, but the default User-Agent survives."""
tool = URLReadTool(headers={"Authorization": "Bearer x", "Accept": "text/plain"})
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"ok")
tool.run(url="https://example.com/f.txt")
headers = fetch.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer x"
assert headers["Accept"] == "text/plain"
assert "crewai-tools URLReadTool" in headers["User-Agent"]
def test_reads_a_real_pdf_end_to_end():
"""Real PDF bytes are extracted page by page."""
pdf_bytes = build_pdf()
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
pdf_bytes, "application/pdf", "https://example.com/report.pdf"
)
result = tool.run(url="https://example.com/report.pdf")
assert "Page 1:" in result
assert "Quarterly revenue was 42" in result
def test_corrupt_pdf_reports_error_without_raising():
"""A malformed PDF becomes an error string, not a traceback."""
pytest.importorskip("pymupdf")
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
b"%PDF-1.4 not really a pdf", "application/pdf"
)
result = tool.run(url="https://example.com/report.pdf")
assert result.startswith("Error: Failed to read PDF content")
class TestSafeGetBounded:
"""Tests for the bounded-fetch helper itself."""
def test_returns_body_content_type_and_final_url(self):
"""The helper reports the body alongside where it ended up."""
response = FakeResponse(b"payload", "text/plain", "https://example.com/final")
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
body, content_type, final_url = safe_get_bounded(
"https://example.com/start", max_bytes=1024
)
assert body == b"payload"
assert content_type == "text/plain"
assert final_url == "https://example.com/final"
assert response.closed
def test_rejects_body_over_the_limit(self):
"""Crossing max_bytes raises rather than truncating silently."""
response = FakeResponse(b"x" * 100, chunk_size=10)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError, match="exceeds the 25 byte limit"):
safe_get_bounded("https://example.com/big", max_bytes=25)
assert response.closed
def test_oversized_error_names_the_url_that_served_the_body(self):
"""After a redirect the requested URL is not the one that sent it."""
response = FakeResponse(
b"x" * 100, url="https://cdn.example.com/final", chunk_size=10
)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError, match="https://cdn.example.com/final"):
safe_get_bounded("https://example.com/start", max_bytes=25)
@pytest.mark.parametrize("max_bytes", [0, -1])
def test_non_positive_max_bytes_fails_before_requesting(self, max_bytes):
"""A misconfigured cap is caught without issuing a request."""
with patch("crewai_tools.security.safe_requests.safe_get") as safe_get:
with pytest.raises(ValueError, match="max_bytes must be positive"):
safe_get_bounded("https://example.com/f", max_bytes=max_bytes)
safe_get.assert_not_called()
def test_stops_reading_once_the_limit_is_crossed(self):
"""The cap must abandon the stream, not buffer the whole body first."""
chunks_yielded = 0
class CountingResponse(FakeResponse):
def iter_content(self, chunk_size: int = 65536):
nonlocal chunks_yielded
for _ in range(1000):
chunks_yielded += 1
yield b"x" * 10
response = CountingResponse()
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError):
safe_get_bounded("https://example.com/huge", max_bytes=25)
assert chunks_yielded == 3
def test_error_status_raises(self):
"""An error status propagates as an HTTPError."""
response = FakeResponse(b"nope", status_code=404)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(requests.HTTPError):
safe_get_bounded("https://example.com/missing", max_bytes=1024)
assert response.closed
def test_closes_redirect_hops(self):
"""Streamed redirect hops hold connections until closed."""
hop = FakeResponse(b"", status_code=302)
response = FakeResponse(b"done")
response.history = [hop]
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
safe_get_bounded("https://example.com/start", max_bytes=1024)
assert hop.closed
assert response.closed
def test_requests_are_streamed(self):
"""Streaming is what lets an oversized body be abandoned early."""
response = FakeResponse(b"ok")
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
) as safe_get:
safe_get_bounded("https://example.com/f", max_bytes=1024)
assert safe_get.call_args.kwargs["stream"] is True

View File

@@ -112,6 +112,80 @@ def test_safe_get_fails_closed_after_too_many_redirects(
safe_get("http://public.example/start", max_redirects=1, timeout=15)
def _closable_response(
url: str, status_code: int, *, location: str | None = None, closed: list[str]
) -> requests.Response:
"""Build a response that records its own URL when closed."""
response = _response(url, status_code, location=location)
response.close = lambda: closed.append(url) # type: ignore[method-assign]
return response
def test_safe_get_closes_earlier_hops_after_too_many_redirects(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
"""Hops accumulated before the failure must not be left open.
Under stream=True each hop holds its connection until its body is read or
closed, and a caller handed an exception has no handle on them.
"""
closed: list[str] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
return _closable_response(
url, 302, location="http://safe.example/again", closed=closed
)
_mock_get(monkeypatch, fake_get)
with pytest.raises(ValueError, match="Too many redirects"):
safe_get("http://public.example/start", max_redirects=2, timeout=15, stream=True)
assert len(closed) == 3
def test_safe_get_closes_earlier_hops_when_a_redirect_is_rejected(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
"""A hop rejected mid-chain still releases the connections already open."""
closed: list[str] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
if url == "http://public.example/start":
return _closable_response(
url, 302, location="http://safe.example/next", closed=closed
)
return _closable_response(
url, 302, location="http://169.254.169.254/latest", closed=closed
)
_mock_get(monkeypatch, fake_get)
with pytest.raises(ValueError, match="private/reserved IP"):
safe_get("http://public.example/start", timeout=15, stream=True)
assert closed == ["http://safe.example/next", "http://public.example/start"]
def test_safe_get_leaves_hops_open_on_success(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
"""On success the hops belong to the caller, via response.history."""
closed: list[str] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
if url == "http://public.example/start":
return _closable_response(url, 302, location="/final", closed=closed)
return _closable_response(url, 200, closed=closed)
_mock_get(monkeypatch, fake_get)
response = safe_get("http://public.example/start", timeout=15, stream=True)
assert closed == []
assert len(response.history) == 1
def test_safe_get_strips_credentials_on_cross_origin_redirect(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:

View File

@@ -26885,6 +26885,148 @@
"type": "object"
}
},
{
"description": "A tool that reads the content at a URL and returns it as text. To use this tool, provide a 'url' parameter with an http:// or https:// address. PDF, DOCX, HTML, JSON, XML, CSV and plain-text responses are converted to text; other binary types are rejected. URLs that resolve to private or internal network addresses are refused, as are responses over the tool's size limit. Optionally provide 'start_line' and 'line_count' to read only part of the content.",
"env_vars": [],
"humanized_name": "Read content from a URL",
"init_params_schema": {
"$defs": {
"EnvVar": {
"properties": {
"default": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Default"
},
"description": {
"title": "Description",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"required": {
"default": true,
"title": "Required",
"type": "boolean"
}
},
"required": [
"name",
"description"
],
"title": "EnvVar",
"type": "object"
},
"ToolFailurePolicy": {
"description": "How an agent reacts when one of its tools reports a failure.",
"enum": [
"ignore",
"warn",
"raise"
],
"title": "ToolFailurePolicy",
"type": "string"
}
},
"description": "Read the content at an arbitrary URL and return it as text.\n\nUnlike :class:`~crewai_tools.tools.file_read_tool.file_read_tool.FileReadTool`,\nwhich is confined to the local filesystem, this tool performs network\nrequests to addresses the caller -- often an LLM -- chooses at runtime. It\nis a separate tool for exactly that reason: granting it is granting network\negress, and that should be a deliberate choice rather than a flag on a\nfilesystem tool.\n\nResponses are decoded to text according to their content type. PDF and DOCX\nbodies have their text extracted, HTML is stripped to visible text, and\ntext-shaped types (plain text, Markdown, JSON, XML, YAML, CSV) are decoded\nas-is. Any other type is refused rather than returned as base64, keeping\nthis tool's output text-only.\n\nSecurity:\n Requests go through :func:`~crewai_tools.security.safe_requests.safe_get_bounded`,\n which resolves each hostname and rejects it when any resolved address is\n private, loopback, link-local, or otherwise reserved -- covering cloud\n metadata endpoints and internal services. Redirects are never followed\n automatically: every hop is revalidated, and credentials are dropped on\n cross-origin hops. Bodies over ``max_bytes`` are abandoned mid-stream.\n\n Two risks are not closed here. Validation resolves the hostname and\n requests resolves it again when connecting, so a DNS entry that changes\n between those lookups can still redirect the connection (DNS\n rebinding); closing that requires pinning the connection to the\n validated address. And the returned text is untrusted remote content\n flowing into an agent's context -- a fetched page can attempt to\n instruct the agent. Neither is addressable by input validation alone;\n network egress policy and prompt-level handling cover them.\n\nArgs:\n max_bytes (int): Largest response body to accept, in decoded bytes.\n Defaults to 5 MiB.\n timeout (float): Per-request timeout in seconds. Defaults to 30.\n headers (Optional[dict[str, str]]): Extra request headers. Developer\n supplied, not chosen by the model.\n encoding (Optional[str]): Force a text encoding instead of honoring the\n charset the server declares.\n **kwargs: Additional keyword arguments passed to BaseTool.\n\nExample:\n >>> tool = URLReadTool()\n >>> content = tool.run(url=\"https://example.com/report.pdf\")\n >>> head = tool.run(url=\"https://example.com/data.csv\", line_count=20)",
"properties": {
"encoding": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Encoding"
},
"headers": {
"anyOf": [
{
"additionalProperties": {
"type": "string"
},
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Headers"
},
"max_bytes": {
"default": 5242880,
"title": "Max Bytes",
"type": "integer"
},
"timeout": {
"default": 30,
"title": "Timeout",
"type": "number"
}
},
"required": [],
"title": "URLReadTool",
"type": "object"
},
"name": "URLReadTool",
"package_dependencies": [],
"run_params_schema": {
"description": "Input for URLReadTool.",
"properties": {
"line_count": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Number of lines to read. If None, reads the entire content",
"title": "Line Count"
},
"start_line": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": 1,
"description": "Line number to start reading from (1-indexed)",
"title": "Start Line"
},
"url": {
"description": "The http:// or https:// URL to read. Addresses that resolve to private or internal networks are refused.",
"title": "Url",
"type": "string"
}
},
"required": [
"url"
],
"title": "URLReadToolSchema",
"type": "object"
}
},
{
"description": "This tool uses OpenAI's Vision API to describe the contents of an image.",
"env_vars": [

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.11",
"crewai-cli==1.15.11",
"crewai-core==1.15.12",
"crewai-cli==1.15.12",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.15.11",
"crewai-tools==1.15.12",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.15.11"
__version__ = "1.15.12"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -4,7 +4,7 @@ import json
import logging
from typing import Any, ParamSpec, TypeVar
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from typing_extensions import TypeIs
from crewai.flow.flow_definition import (
@@ -432,6 +432,20 @@ def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
return methods
def _flow_definition_validation_error(
flow_class: type, exc: ValidationError
) -> ValueError:
errors = exc.errors()
if errors:
detail = errors[0].get("msg", str(exc))
if isinstance(detail, str) and detail.startswith("Value error, "):
detail = detail.removeprefix("Value error, ")
else:
detail = str(exc)
class_name = getattr(flow_class, "__name__", "Flow")
return ValueError(f"Invalid flow definition for {class_name}: {detail}")
def _build_flow_definition_from_class(
flow_class: type,
namespace: dict[str, Any] | None = None,
@@ -455,15 +469,18 @@ def _build_flow_definition_from_class(
if docstring:
description = docstring.strip()
definition = FlowDefinition(
name=getattr(flow_class, "__name__", "Flow"),
description=description,
state=_build_state_definition(flow_class),
config=_build_config_definition(flow_class),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class),
methods=methods,
)
try:
definition = FlowDefinition(
name=getattr(flow_class, "__name__", "Flow"),
description=description,
state=_build_state_definition(flow_class),
config=_build_config_definition(flow_class),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class),
methods=methods,
)
except ValidationError as exc:
raise _flow_definition_validation_error(flow_class, exc) from exc
log_flow_definition_issues(definition)
return definition

View File

@@ -775,7 +775,11 @@ class FlowDefinition(BaseModel):
for method_name, method in self.methods.items():
if _condition_references(method.listen, method_name):
raise ValueError(
f"methods.{method_name}.listen must not reference itself"
_self_listen_error(
method_name=method_name,
listen=method.listen,
definition=self,
)
)
return self
@@ -888,6 +892,39 @@ def _condition_references(condition: FlowDefinitionCondition | None, name: str)
)
def _format_listen_condition(condition: FlowDefinitionCondition | None) -> str:
if condition is None:
return "None"
return repr(condition)
def _self_listen_error(
*,
method_name: str,
listen: FlowDefinitionCondition | None,
definition: FlowDefinition,
) -> str:
path = f"methods.{method_name}.listen"
listen_display = _format_listen_condition(listen)
conversational = (
definition.conversational is not None and definition.conversational.enabled
)
if conversational:
return (
f"{path} listen condition {listen_display} matches the handler name "
f"{method_name!r}. In conversational flows, @listen labels are router "
"route names — they share the same trigger namespace as method completion "
"events, so this handler would re-run in a loop. Rename the handler "
f"(for example, handle_{method_name}) or use a different route label."
)
return (
f"{path} listen condition {listen_display} references the handler name "
f"{method_name!r}. A listener triggered by its own completion creates an "
"infinite loop. Listen to a different method or event, or rename the handler."
)
def _validate_action_cel(
action: FlowActionDefinition,
*,

View File

@@ -2158,7 +2158,7 @@ def test_self_listening_method_is_rejected():
def process(self):
pass
with pytest.raises(ValueError, match="methods.process.listen"):
with pytest.raises(ValueError, match="Invalid flow definition for SelfListenFlow"):
SelfListenFlow.flow_definition()
@@ -2176,7 +2176,7 @@ def test_or_condition_self_listen_is_rejected():
def process(self):
pass
with pytest.raises(ValueError, match="methods.process.listen"):
with pytest.raises(ValueError, match="Invalid flow definition for OrSelfListenFlow"):
OrSelfListenFlow.flow_definition()
@@ -2190,7 +2190,7 @@ def test_router_self_listening_method_is_rejected():
def route(self):
return "done"
with pytest.raises(ValueError, match="methods.route.listen"):
with pytest.raises(ValueError, match="Invalid flow definition for RouterSelfListenFlow"):
RouterSelfListenFlow.flow_definition()

View File

@@ -1231,7 +1231,7 @@ def test_static_string_listener_is_allowed_by_contract():
@pytest.mark.parametrize("listen", ["publish", {"or": ["publish", "revise"]}])
@pytest.mark.parametrize("router_enabled", [False, True])
def test_flow_definition_rejects_method_self_listen(listen, router_enabled):
with pytest.raises(ValueError, match="methods.publish.listen"):
with pytest.raises(ValueError, match="listen condition"):
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
@@ -1252,6 +1252,49 @@ def test_flow_definition_rejects_method_self_listen(listen, router_enabled):
)
def test_flow_definition_rejects_conversational_route_handler_name_collision():
with pytest.raises(ValueError, match=r"listen condition 'create_video'"):
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "VideoFlow",
"conversational": {
"enabled": True,
"router": {
"route_descriptions": {
"create_video": "User wants a new video.",
},
},
},
"methods": {
"begin": {
"do": {"ref": "loaded_flows:VideoFlow.begin"},
"start": True,
},
"create_video": {
"do": {"ref": "loaded_flows:VideoFlow.create_video"},
"listen": "create_video",
},
},
}
)
def test_build_flow_definition_wraps_validation_error_with_class_name():
class VideoFlow(Flow):
conversational = True
@listen("create_video")
def create_video(self):
return "made a video"
with pytest.raises(ValueError, match="Invalid flow definition for VideoFlow"):
VideoFlow.flow_definition()
with pytest.raises(ValueError, match="Invalid flow definition for VideoFlow"):
VideoFlow()
def test_start_false_not_classified_as_start_method():
definition = flow_definition.FlowDefinition.from_declaration(contents=
{

View File

@@ -1698,6 +1698,7 @@ class TestPlatformActionTool:
return mod.CrewAIPlatformActionTool(
description="Send a Slack message",
app="slack",
action_name="slackbot_send_message",
action_schema={
"function": {

View File

@@ -39,9 +39,13 @@ devtools release 1.10.3 --skip-enterprise # skip enterprise release phase
7. Opens a `[docs-freeze]` PR against main, polls until merged
8. Tags main and creates GitHub release
9. Triggers PyPI publish workflow
10. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync`
11. Creates enterprise bump PR, polls until merged
12. Tags and creates GitHub release on enterprise repo
10. Updates `crewAIInc/crew_deployment_test` to the exact CrewAI version,
creates a bump PR, and waits for it to merge
11. Updates `crewAIInc/flow_deployment_test` to the exact CrewAI version,
creates a bump PR, and waits for it to merge
12. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync`
13. Creates enterprise bump PR, polls until merged
14. Tags and creates GitHub release on enterprise repo
> The `docs-snapshots` CI guard rejects writes under `docs/v*/` and deletions/renames in `docs/images/` unless the PR title starts with `[docs-freeze]`. The release CLI sets that prefix automatically; manual edits to a frozen snapshot need the same prefix to land.
>
@@ -66,4 +70,4 @@ Tag and release only (phase 2 of `release`). Run after the bump PR is merged.
devtools tag
devtools tag --no-edit
devtools tag --dry-run
```
```

View File

@@ -15,6 +15,7 @@ dependencies = [
"openai>=1.83.0,<3",
"python-dotenv>=1.2.2,<2",
"pygithub~=1.59.1",
"pyyaml~=6.0",
"rich>=13.9.4",
]

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.15.11"
__version__ = "1.15.12"

View File

@@ -4,6 +4,7 @@ from collections.abc import Mapping
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
@@ -20,6 +21,7 @@ from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Confirm
import tomlkit
import yaml
from crewai_devtools.docs_check import docs_check
from crewai_devtools.docs_versioning import (
@@ -1421,7 +1423,10 @@ def _repin_crewai_install(run_value: str, version: str) -> str:
return "".join(result)
_DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test"
_DEPLOYMENT_TEST_REPOS: Final[tuple[str, ...]] = (
"crewAIInc/crew_deployment_test",
"crewAIInc/flow_deployment_test",
)
_PUBLISHED_WORKSPACE_PACKAGES: Final[tuple[str, ...]] = (
"crewai",
@@ -1435,26 +1440,164 @@ _PYPI_POLL_INTERVAL: Final[int] = 15
_PYPI_POLL_TIMEOUT: Final[int] = 600
def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
"""Update the deployment test repo to pin the new crewai version.
_CREWAI_REQUIREMENT_PATTERN: Final[re.Pattern[str]] = re.compile(
r"^crewai(?:\s*\[[^\]]+\])?(?![\w-])"
r"\s*(?:(?P<operator>===|==|~=|!=|>=|<=|>|<)\s*"
r"(?P<version>[^\s;]+))?",
re.IGNORECASE,
)
Clones the repo, updates the crewai[tools] pin in pyproject.toml
def _crewai_requirement_pin(requirement: str) -> str | None:
"""Return an exact CrewAI pin, or an empty string for a non-exact pin."""
match = _CREWAI_REQUIREMENT_PATTERN.match(requirement.strip())
if not match:
return None
if match.group("operator") != "==":
return ""
return match.group("version") or ""
def _pyproject_crewai_requirements(content: str) -> list[tuple[str, str]]:
"""Collect active CrewAI dependency requirements from pyproject content."""
requirements: list[tuple[str, str]] = []
doc = tomlkit.parse(content)
for key in ("dependencies", "optional-dependencies"):
deps = doc.get("project", {}).get(key)
if deps is None:
continue
dep_lists = deps.values() if isinstance(deps, Mapping) else [deps]
for dep_list in dep_lists:
for dep in dep_list:
spec = str(dep)
pin = _crewai_requirement_pin(spec)
if pin is not None:
requirements.append((spec, pin))
return requirements
def _workflow_run_commands(content: str) -> list[str]:
"""Extract shell commands from workflow ``run`` values."""
commands: list[str] = []
def collect_run_commands(node: object) -> None:
if isinstance(node, Mapping):
for key, value in node.items():
if key == "run" and isinstance(value, str):
commands.append(value)
collect_run_commands(value)
elif isinstance(node, list):
for value in node:
collect_run_commands(value)
collect_run_commands(yaml.safe_load(content))
return commands
def _workflow_crewai_requirements(content: str) -> list[tuple[str, str]]:
"""Collect CrewAI requirements from executable workflow install commands."""
requirements: list[tuple[str, str]] = []
for command in _workflow_run_commands(content):
normalized = command.replace("\\\n", " ")
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|\n")
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = "#"
try:
tokens = list(lexer)
except ValueError:
continue
index = 0
while index < len(tokens):
command_lengths = (
(tokens[index : index + 3] == ["uv", "pip", "install"], 3),
(tokens[index : index + 2] == ["uv", "add"], 2),
(
tokens[index : index + 2]
in (["pip", "install"], ["pip3", "install"]),
2,
),
(
tokens[index : index + 4]
in (
["python", "-m", "pip", "install"],
["python3", "-m", "pip", "install"],
),
4,
),
)
install_length = next(
(length for matched, length in command_lengths if matched),
0,
)
if not install_length:
index += 1
continue
index += install_length
while index < len(tokens) and tokens[index] not in {
";",
"&&",
"||",
"|",
"\n",
}:
argument = tokens[index]
pin = _crewai_requirement_pin(argument)
if pin is not None:
requirements.append((argument, pin))
index += 1
return requirements
def _validate_deployment_repo_crewai_pin(
repo_dir: Path,
pyproject_content: str,
version: str,
) -> None:
"""Fail unless every effective canary CrewAI requirement has the exact pin."""
requirements = _pyproject_crewai_requirements(pyproject_content)
workflows_dir = repo_dir / ".github" / "workflows"
if workflows_dir.exists():
for workflow in workflows_dir.iterdir():
if workflow.is_file() and workflow.suffix in (".yml", ".yaml"):
requirements.extend(
_workflow_crewai_requirements(workflow.read_text(encoding="utf-8"))
)
if not requirements:
raise RuntimeError(f"No effective CrewAI dependency found in {repo_dir.name}")
mismatches = [spec for spec, pin in requirements if pin != version]
if mismatches:
found = ", ".join(repr(spec) for spec in mismatches)
raise RuntimeError(
f"CrewAI dependencies in {repo_dir.name} must all pin {version}; "
f"found {found}"
)
def _update_deployment_test_repo(repo: str, version: str, is_prerelease: bool) -> None:
"""Update a deployment test repo to pin the new crewai version.
Clones the repo, updates the CrewAI pin in pyproject.toml
and any crewai[extras] pins in .github/workflows, regenerates the
lockfile, commits to a branch, pushes, opens a PR against main,
then polls until the PR is merged (or closed).
Args:
repo: GitHub repository containing the deployment canary.
version: New crewai version string.
is_prerelease: Whether this is a pre-release version.
"""
console.print(
f"\n[bold cyan]Updating {_DEPLOYMENT_TEST_REPO} to {version}[/bold cyan]"
)
console.print(f"\n[bold cyan]Updating {repo} to {version}[/bold cyan]")
with tempfile.TemporaryDirectory() as tmp:
repo_dir = Path(tmp) / "crew_deployment_test"
run_command(["gh", "repo", "clone", _DEPLOYMENT_TEST_REPO, str(repo_dir)])
console.print(f"[green]✓[/green] Cloned {_DEPLOYMENT_TEST_REPO}")
repo_dir = Path(tmp) / repo.rsplit("/", 1)[-1]
run_command(["gh", "repo", "clone", repo, str(repo_dir)])
console.print(f"[green]✓[/green] Cloned {repo}")
pyproject = repo_dir / "pyproject.toml"
content = pyproject.read_text()
@@ -1462,11 +1605,9 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
pyproject_changed = new_content != content
if pyproject_changed:
pyproject.write_text(new_content)
console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}")
console.print(f"[green]✓[/green] Updated crewai pin to {version}")
else:
console.print(
"[yellow]Warning:[/yellow] No crewai[tools] pin found to update"
)
console.print("[yellow]Warning:[/yellow] No crewai pin found to update")
updated_workflows = _update_repo_workflows_crewai_pins(repo_dir, version)
for wf in updated_workflows:
@@ -1474,6 +1615,8 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
f"[green]✓[/green] Updated crewai pin in {wf.relative_to(repo_dir)}"
)
_validate_deployment_repo_crewai_pin(repo_dir, new_content, version)
if not pyproject_changed and not updated_workflows:
console.print("[yellow]Nothing to update; skipping commit and PR.[/yellow]")
return
@@ -1535,12 +1678,18 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
],
cwd=repo_dir,
)
console.print(f"[green]✓[/green] Opened PR on {_DEPLOYMENT_TEST_REPO}")
console.print(f"[green]✓[/green] Opened PR on {repo}")
console.print(f"[cyan]PR URL:[/cyan] {pr_url.strip()}")
_wait_for_pr_merged(branch, repo_dir)
def _update_deployment_test_repos(version: str, is_prerelease: bool) -> None:
"""Pin and merge the release version in every deployment canary repo."""
for repo in _DEPLOYMENT_TEST_REPOS:
_update_deployment_test_repo(repo, version, is_prerelease)
def _wait_for_pypi(package: str, version: str) -> None:
"""Poll PyPI until a specific package version is available.
@@ -2352,13 +2501,13 @@ def release(
try:
if not dry_run:
_update_deployment_test_repo(version, is_prerelease)
_update_deployment_test_repos(version, is_prerelease)
except BaseException as e:
_print_release_error(e)
_resume_hint(
f"Phase 2 failed updating deployment test repo. "
f"Phase 2 failed updating deployment test repos. "
f"Tag, release, and PyPI are done.\n"
f"Fix the issue and update {_DEPLOYMENT_TEST_REPO} manually."
"Fix the issue and update the Crew and Flow canary repos manually."
f"{enterprise_hint}"
)
sys.exit(1)

View File

@@ -3,14 +3,217 @@
from pathlib import Path
from textwrap import dedent
from crewai_devtools import cli as devtools_cli
from crewai_devtools.cli import (
_DEFAULT_WORKSPACE_PACKAGES,
_pin_crewai_deps,
_repin_crewai_install,
_validate_deployment_repo_crewai_pin,
update_pyproject_dependencies,
update_pyproject_version,
update_template_dependencies,
)
import pytest
def test_release_updates_crew_and_flow_canary_repositories(monkeypatch) -> None:
updates = []
monkeypatch.setattr(
devtools_cli,
"_update_deployment_test_repo",
lambda repo, version, is_prerelease: updates.append(
(repo, version, is_prerelease)
),
)
devtools_cli._update_deployment_test_repos("2.0.0a1", True)
assert updates == [
("crewAIInc/crew_deployment_test", "2.0.0a1", True),
("crewAIInc/flow_deployment_test", "2.0.0a1", True),
]
def test_deployment_repo_validation_rejects_missing_crewai_pin(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="No effective CrewAI dependency"):
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_accepts_workflow_pin(tmp_path: Path) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text('run: uv pip install "crewai[a2a]==2.0.0"\n')
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
@pytest.mark.parametrize(
"run_value",
[
"'uv pip install \"crewai==2.0.0\"'",
'"uv pip install \\"crewai==2.0.0\\""',
],
)
def test_deployment_repo_validation_accepts_quoted_workflow_pin(
tmp_path: Path,
run_value: str,
) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text(
f"run: {run_value}\n",
encoding="utf-8",
)
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_rejects_mixed_versions(tmp_path: Path) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text('run: uv pip install "crewai[a2a]==2.0.0"\n')
with pytest.raises(RuntimeError, match=r"must all pin 2\.0\.0"):
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["crewai==1.0.0"]\n',
"2.0.0",
)
def test_deployment_repo_validation_ignores_comments_and_echo(tmp_path: Path) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text(
'run: echo "crewai==2.0.0"\n# run: pip install crewai==2.0.0\n'
)
with pytest.raises(RuntimeError, match="No effective CrewAI dependency"):
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_ignores_pyproject_comment_pin(
tmp_path: Path,
) -> None:
with pytest.raises(RuntimeError, match=r"must all pin 2\.0\.0"):
_validate_deployment_repo_crewai_pin(
tmp_path,
(
"# documented pin: crewai==2.0.0\n"
'[project]\ndependencies = ["crewai>=1.0"]\n'
),
"2.0.0",
)
def test_deployment_repo_validation_accepts_spaced_extras_and_marker(
tmp_path: Path,
) -> None:
_validate_deployment_repo_crewai_pin(
tmp_path,
(
"[project]\ndependencies = [\n"
" \"crewai[tools, embeddings]==2.0.0; python_version >= '3.10'\",\n"
"]\n"
),
"2.0.0",
)
def test_deployment_repo_validation_reads_multiline_workflow_install(
tmp_path: Path,
) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text(
"steps:\n"
" - name: Install\n"
" run: |\n"
" uv pip install \\\n"
" \"crewai[tools, embeddings]==2.0.0; python_version >= '3.10'\"\n"
)
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_reads_install_after_comment(
tmp_path: Path,
) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text(
"steps:\n"
" - name: Install\n"
" run: |\n"
" # Install the canary dependency\n"
' uv pip install "crewai==2.0.0"\n',
encoding="utf-8",
)
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_reads_folded_workflow_install(
tmp_path: Path,
) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "test.yml").write_text(
"steps:\n"
" - name: Install\n"
" run: >\n"
" uv pip install\n"
' "crewai==2.0.0"\n',
encoding="utf-8",
)
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
def test_deployment_repo_validation_skips_non_file_workflow_entries(
tmp_path: Path,
) -> None:
workflows = tmp_path / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "ignored.yml").mkdir()
(workflows / "test.yaml").write_text(
'# UTF-8 workflow: déploiement\nrun: uv pip install "crewai==2.0.0"\n',
encoding="utf-8",
)
_validate_deployment_repo_crewai_pin(
tmp_path,
'[project]\ndependencies = ["requests>=2"]\n',
"2.0.0",
)
class TestUpdatePyprojectVersion: