mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-08 00:15:12 +00:00
Merge branch 'main' into devin/1773669058-fix-trained-agents-data-file
This commit is contained in:
@@ -9,14 +9,17 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"Pillow~=12.1.1",
|
||||
"pypdf~=6.7.5",
|
||||
"pypdf~=6.9.1",
|
||||
"python-magic>=0.4.27",
|
||||
"aiocache~=0.12.3",
|
||||
"aiofiles~=24.1.0",
|
||||
"tinytag~=1.10.0",
|
||||
"tinytag~=2.2.1",
|
||||
"av~=13.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.11.0rc1"
|
||||
__version__ = "1.14.2a1"
|
||||
|
||||
@@ -122,7 +122,7 @@ def format_multimodal_content(
|
||||
return content_blocks
|
||||
|
||||
# Use API-specific constraints for OpenAI
|
||||
constraints_key = provider_type
|
||||
constraints_key: str = provider_type
|
||||
if api == "responses" and "openai" in provider_type.lower():
|
||||
constraints_key = "openai_responses"
|
||||
|
||||
@@ -187,7 +187,7 @@ async def aformat_multimodal_content(
|
||||
return content_blocks
|
||||
|
||||
# Use API-specific constraints for OpenAI
|
||||
constraints_key = provider_type
|
||||
constraints_key: str = provider_type
|
||||
if api == "responses" and "openai" in provider_type.lower():
|
||||
constraints_key = "openai_responses"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from crewai_files.core.types import (
|
||||
ProviderName = Literal[
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openai_responses",
|
||||
"gemini",
|
||||
"bedrock",
|
||||
"azure",
|
||||
|
||||
@@ -120,7 +120,7 @@ def optimize_image(
|
||||
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img = img.convert("RGB") # type: ignore[assignment]
|
||||
output_format = "JPEG"
|
||||
else:
|
||||
output_format = img.format or "JPEG"
|
||||
|
||||
@@ -85,7 +85,7 @@ def _get_audio_duration(content: bytes, filename: str | None = None) -> float |
|
||||
Duration in seconds or None if tinytag unavailable.
|
||||
"""
|
||||
try:
|
||||
from tinytag import TinyTag # type: ignore[import-untyped]
|
||||
from tinytag import TinyTag
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"tinytag not installed - cannot validate audio duration. "
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
## Building CrewAI Tools
|
||||
|
||||
This guide shows you how to build high‑quality CrewAI tools that match the patterns in this repository and are ready to be merged. It focuses on: architecture, conventions, environment variables, dependencies, testing, documentation, and a complete example.
|
||||
|
||||
### Who this is for
|
||||
- Contributors creating new tools under `crewai_tools/tools/*`
|
||||
- Maintainers reviewing PRs for consistency and DX
|
||||
|
||||
---
|
||||
|
||||
## Quick‑start checklist
|
||||
1. Create a new folder under `crewai_tools/tools/<your_tool_name>/` with a `README.md` and a `<your_tool_name>.py`.
|
||||
2. Implement a class that ends with `Tool` and subclasses `BaseTool` (or `RagTool` when appropriate).
|
||||
3. Define a Pydantic `args_schema` with explicit field descriptions and validation.
|
||||
4. Declare `env_vars` and `package_dependencies` in the class when needed.
|
||||
5. Lazily initialize clients in `__init__` or `_run` and handle missing credentials with clear errors.
|
||||
6. Implement `_run(...) -> str | dict` and, if needed, `_arun(...)`.
|
||||
7. Add tests under `tests/tools/` (unit, no real network calls; mock or record safely).
|
||||
8. Add a concise tool `README.md` with usage and required env vars.
|
||||
9. If you add optional dependencies, register them in `pyproject.toml` under `[project.optional-dependencies]` and reference that extra in your tool docs.
|
||||
10. Run `uv run pytest` and `pre-commit run -a` locally; ensure green.
|
||||
|
||||
---
|
||||
|
||||
## Tool anatomy and conventions
|
||||
|
||||
### BaseTool pattern
|
||||
All tools follow this structure:
|
||||
|
||||
```python
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
import os
|
||||
from pydantic import BaseModel, Field
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
|
||||
|
||||
class MyToolInput(BaseModel):
|
||||
"""Input schema for MyTool."""
|
||||
query: str = Field(..., description="Your input description here")
|
||||
limit: int = Field(5, ge=1, le=50, description="Max items to return")
|
||||
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name: str = "My Tool"
|
||||
description: str = "Explain succinctly what this tool does and when to use it."
|
||||
args_schema: Type[BaseModel] = MyToolInput
|
||||
|
||||
# Only include when applicable
|
||||
env_vars: List[EnvVar] = [
|
||||
EnvVar(name="MY_API_KEY", description="API key for My service", required=True),
|
||||
]
|
||||
package_dependencies: List[str] = ["my-sdk"]
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
# Lazy import to keep base install light
|
||||
try:
|
||||
import my_sdk # noqa: F401
|
||||
except Exception as exc:
|
||||
raise ImportError(
|
||||
"Missing optional dependency 'my-sdk'. Install with: \n"
|
||||
" uv add crewai-tools --extra my-sdk\n"
|
||||
"or\n"
|
||||
" pip install my-sdk\n"
|
||||
) from exc
|
||||
|
||||
if "MY_API_KEY" not in os.environ:
|
||||
raise ValueError("Environment variable MY_API_KEY is required for MyTool")
|
||||
|
||||
def _run(self, query: str, limit: int = 5, **_: Any) -> str:
|
||||
"""Synchronous execution. Return a concise string or JSON string."""
|
||||
# Implement your logic here; do not print. Return the content.
|
||||
# Handle errors gracefully, return clear messages.
|
||||
return f"Processed {query} with limit={limit}"
|
||||
|
||||
async def _arun(self, *args: Any, **kwargs: Any) -> str:
|
||||
"""Optional async counterpart if your client supports it."""
|
||||
# Prefer delegating to _run when the client is thread-safe
|
||||
return self._run(*args, **kwargs)
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Class name must end with `Tool` to be auto‑discovered by our tooling.
|
||||
- Use `args_schema` for inputs; always include `description` and validation.
|
||||
- Validate env vars early and fail with actionable errors.
|
||||
- Keep outputs deterministic and compact; favor `str` (possibly JSON‑encoded) or small dicts converted to strings.
|
||||
- Avoid printing; return the final string.
|
||||
|
||||
### Error handling
|
||||
- Wrap network and I/O with try/except and return a helpful message. See `BraveSearchTool` and others for patterns.
|
||||
- Validate required inputs and environment configuration with clear messages.
|
||||
- Keep exceptions user‑friendly; do not leak stack traces.
|
||||
|
||||
### Rate limiting and retries
|
||||
- If the upstream API enforces request pacing, implement minimal rate limiting (see `BraveSearchTool`).
|
||||
- Consider idempotency and backoff for transient errors where appropriate.
|
||||
|
||||
### Async support
|
||||
- Implement `_arun` only if your library has a true async client or your sync calls are thread‑safe.
|
||||
- Otherwise, delegate `_arun` to `_run` as in multiple existing tools.
|
||||
|
||||
### Returning values
|
||||
- Return a string (or JSON string) that’s ready to display in an agent transcript.
|
||||
- If returning structured data, keep it small and human‑readable. Use stable keys and ordering.
|
||||
|
||||
---
|
||||
|
||||
## RAG tools and adapters
|
||||
|
||||
If your tool is a knowledge source, consider extending `RagTool` and/or creating an adapter.
|
||||
|
||||
- `RagTool` exposes `add(...)` and a `query(question: str) -> str` contract through an `Adapter`.
|
||||
- See `crewai_tools/tools/rag/rag_tool.py` and adapters like `embedchain_adapter.py` and `lancedb_adapter.py`.
|
||||
|
||||
Minimal adapter example:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
from crewai_tools.tools.rag.rag_tool import Adapter, RagTool
|
||||
|
||||
|
||||
class MemoryAdapter(Adapter):
|
||||
store: list[str] = []
|
||||
|
||||
def add(self, text: str, **_: Any) -> None:
|
||||
self.store.append(text)
|
||||
|
||||
def query(self, question: str) -> str:
|
||||
# naive demo: return all text containing any word from the question
|
||||
tokens = set(question.lower().split())
|
||||
hits = [t for t in self.store if tokens & set(t.lower().split())]
|
||||
return "\n".join(hits) if hits else "No relevant content found."
|
||||
|
||||
|
||||
class MemoryRagTool(RagTool):
|
||||
name: str = "In‑memory RAG"
|
||||
description: str = "Toy RAG that stores text in memory and returns matches."
|
||||
adapter: Adapter = MemoryAdapter()
|
||||
```
|
||||
|
||||
When using external vector DBs (MongoDB, Qdrant, Weaviate), study the existing tools to follow indexing, embedding, and query configuration patterns closely.
|
||||
|
||||
---
|
||||
|
||||
## Toolkits (multiple related tools)
|
||||
|
||||
Some integrations expose a toolkit (a group of tools) rather than a single class. See Bedrock `browser_toolkit.py` and `code_interpreter_toolkit.py`.
|
||||
|
||||
Guidelines:
|
||||
- Provide small, focused `BaseTool` classes for each operation (e.g., `navigate`, `click`, `extract_text`).
|
||||
- Offer a helper `create_<name>_toolkit(...) -> Tuple[ToolkitClass, List[BaseTool]]` to create tools and manage resources.
|
||||
- If you open external resources (browsers, interpreters), support cleanup methods and optionally context manager usage.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables and dependencies
|
||||
|
||||
### env_vars
|
||||
- Declare as `env_vars: List[EnvVar]` with `name`, `description`, `required`, and optional `default`.
|
||||
- Validate presence in `__init__` or on first `_run` call.
|
||||
|
||||
### Dependencies
|
||||
- List runtime packages in `package_dependencies` on the class.
|
||||
- If they are genuinely optional, add an extra under `[project.optional-dependencies]` in `pyproject.toml` (e.g., `tavily-python`, `serpapi`, `scrapfly-sdk`).
|
||||
- Use lazy imports to avoid hard deps for users who don’t need the tool.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Place tests under `tests/tools/` and follow these rules:
|
||||
- Do not hit real external services in CI. Use mocks, fakes, or recorded fixtures where allowed.
|
||||
- Validate input validation, env var handling, error messages, and happy path output formatting.
|
||||
- Keep tests fast and deterministic.
|
||||
|
||||
Example skeleton (`tests/tools/my_tool_test.py`):
|
||||
|
||||
```python
|
||||
import os
|
||||
import pytest
|
||||
from crewai_tools.tools.my_tool.my_tool import MyTool
|
||||
|
||||
|
||||
def test_requires_env_var(monkeypatch):
|
||||
monkeypatch.delenv("MY_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
MyTool()
|
||||
|
||||
|
||||
def test_happy_path(monkeypatch):
|
||||
monkeypatch.setenv("MY_API_KEY", "test")
|
||||
tool = MyTool()
|
||||
result = tool.run(query="hello", limit=2)
|
||||
assert "hello" in result
|
||||
```
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
pre-commit run -a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Each tool must include a `README.md` in its folder with:
|
||||
- What it does and when to use it
|
||||
- Required env vars and optional extras (with install snippet)
|
||||
- Minimal usage example
|
||||
|
||||
Update the root `README.md` only if the tool introduces a new category or notable capability.
|
||||
|
||||
---
|
||||
|
||||
## Discovery and specs
|
||||
|
||||
Our internal tooling discovers classes whose names end with `Tool`. Keep your class exported from the module path under `crewai_tools/tools/...` to be picked up by scripts like `crewai_tools.generate_tool_specs.py`.
|
||||
|
||||
---
|
||||
|
||||
## Full example: “Weather Search Tool”
|
||||
|
||||
This example demonstrates: `args_schema`, `env_vars`, `package_dependencies`, lazy imports, validation, and robust error handling.
|
||||
|
||||
```python
|
||||
# file: crewai_tools/tools/weather_tool/weather_tool.py
|
||||
from typing import Any, List, Optional, Type
|
||||
import os
|
||||
import requests
|
||||
from pydantic import BaseModel, Field
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
|
||||
|
||||
class WeatherToolInput(BaseModel):
|
||||
"""Input schema for WeatherTool."""
|
||||
city: str = Field(..., description="City name, e.g., 'Berlin'")
|
||||
country: Optional[str] = Field(None, description="ISO country code, e.g., 'DE'")
|
||||
units: str = Field(
|
||||
default="metric",
|
||||
description="Units system: 'metric' or 'imperial'",
|
||||
pattern=r"^(metric|imperial)$",
|
||||
)
|
||||
|
||||
|
||||
class WeatherTool(BaseTool):
|
||||
name: str = "Weather Search"
|
||||
description: str = (
|
||||
"Look up current weather for a city using a public weather API."
|
||||
)
|
||||
args_schema: Type[BaseModel] = WeatherToolInput
|
||||
|
||||
env_vars: List[EnvVar] = [
|
||||
EnvVar(
|
||||
name="WEATHER_API_KEY",
|
||||
description="API key for the weather service",
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
package_dependencies: List[str] = ["requests"]
|
||||
|
||||
base_url: str = "https://api.openweathermap.org/data/2.5/weather"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if "WEATHER_API_KEY" not in os.environ:
|
||||
raise ValueError("WEATHER_API_KEY is required for WeatherTool")
|
||||
|
||||
def _run(self, city: str, country: Optional[str] = None, units: str = "metric") -> str:
|
||||
try:
|
||||
q = f"{city},{country}" if country else city
|
||||
params = {
|
||||
"q": q,
|
||||
"units": units,
|
||||
"appid": os.environ["WEATHER_API_KEY"],
|
||||
}
|
||||
resp = requests.get(self.base_url, params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
main = data.get("weather", [{}])[0].get("main", "Unknown")
|
||||
desc = data.get("weather", [{}])[0].get("description", "")
|
||||
temp = data.get("main", {}).get("temp")
|
||||
feels = data.get("main", {}).get("feels_like")
|
||||
city_name = data.get("name", city)
|
||||
|
||||
return (
|
||||
f"Weather in {city_name}: {main} ({desc}). "
|
||||
f"Temperature: {temp}°, feels like {feels}°."
|
||||
)
|
||||
except requests.Timeout:
|
||||
return "Weather service timed out. Please try again later."
|
||||
except requests.HTTPError as e:
|
||||
return f"Weather service error: {e.response.status_code} {e.response.text[:120]}"
|
||||
except Exception as e:
|
||||
return f"Unexpected error fetching weather: {e}"
|
||||
```
|
||||
|
||||
Folder layout:
|
||||
|
||||
```
|
||||
crewai_tools/tools/weather_tool/
|
||||
├─ weather_tool.py
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
And `README.md` should document env vars and usage.
|
||||
|
||||
---
|
||||
|
||||
## PR checklist
|
||||
- [ ] Tool lives under `crewai_tools/tools/<name>/`
|
||||
- [ ] Class ends with `Tool` and subclasses `BaseTool` (or `RagTool`)
|
||||
- [ ] Precise `args_schema` with descriptions and validation
|
||||
- [ ] `env_vars` declared (if any) and validated
|
||||
- [ ] `package_dependencies` and optional extras added in `pyproject.toml` (if any)
|
||||
- [ ] Clear error handling; no prints
|
||||
- [ ] Unit tests added (`tests/tools/`), fast and deterministic
|
||||
- [ ] Tool `README.md` with usage and env vars
|
||||
- [ ] `pre-commit` and `pytest` pass locally
|
||||
|
||||
---
|
||||
|
||||
## Tips for great DX
|
||||
- Keep responses short and useful—agents quote your tool output directly.
|
||||
- Validate early; fail fast with actionable guidance.
|
||||
- Prefer lazy imports; minimize default install surface.
|
||||
- Mirror patterns from similar tools in this repo for a consistent developer experience.
|
||||
|
||||
Happy building!
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests~=2.32.5",
|
||||
"docker~=7.1.0",
|
||||
"crewai==1.11.0rc1",
|
||||
"crewai==1.14.2a1",
|
||||
"tiktoken~=0.8.0",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
@@ -142,6 +141,9 @@ contextual = [
|
||||
]
|
||||
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -35,9 +35,6 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import (
|
||||
from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import (
|
||||
CodeDocsSearchTool,
|
||||
)
|
||||
from crewai_tools.tools.code_interpreter_tool.code_interpreter_tool import (
|
||||
CodeInterpreterTool,
|
||||
)
|
||||
from crewai_tools.tools.composio_tool.composio_tool import ComposioTool
|
||||
from crewai_tools.tools.contextualai_create_agent_tool.contextual_create_agent_tool import (
|
||||
ContextualAICreateAgentTool,
|
||||
@@ -225,7 +222,6 @@ __all__ = [
|
||||
"BrowserbaseLoadTool",
|
||||
"CSVSearchTool",
|
||||
"CodeDocsSearchTool",
|
||||
"CodeInterpreterTool",
|
||||
"ComposioTool",
|
||||
"ContextualAICreateAgentTool",
|
||||
"ContextualAIParseTool",
|
||||
@@ -309,4 +305,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.11.0rc1"
|
||||
__version__ = "1.14.2a1"
|
||||
|
||||
@@ -136,7 +136,7 @@ class EnterpriseActionTool(BaseTool):
|
||||
enum_values = schema["enum"]
|
||||
if not enum_values:
|
||||
return self._map_json_type_to_python(json_type)
|
||||
return Literal[tuple(enum_values)] # type: ignore[return-value]
|
||||
return Literal[tuple(enum_values)]
|
||||
|
||||
if json_type == "array":
|
||||
items_schema = schema.get("items", {"type": "string"})
|
||||
@@ -155,7 +155,7 @@ class EnterpriseActionTool(BaseTool):
|
||||
full_model_name = f"{self._base_name}{model_name}"
|
||||
|
||||
if full_model_name in self._model_registry:
|
||||
return self._model_registry[full_model_name]
|
||||
return cast(type[Any], self._model_registry[full_model_name])
|
||||
|
||||
properties = schema.get("properties", {})
|
||||
required_fields = schema.get("required", [])
|
||||
@@ -178,19 +178,19 @@ class EnterpriseActionTool(BaseTool):
|
||||
field_definitions[prop_name] = self._create_field_definition(
|
||||
prop_type,
|
||||
is_required,
|
||||
prop_desc, # type: ignore[arg-type]
|
||||
prop_desc,
|
||||
)
|
||||
|
||||
try:
|
||||
nested_model = create_model(full_model_name, **field_definitions) # type: ignore[call-overload]
|
||||
self._model_registry[full_model_name] = nested_model
|
||||
return nested_model
|
||||
return cast(type[Any], nested_model)
|
||||
except Exception:
|
||||
return dict
|
||||
|
||||
def _create_field_definition(
|
||||
self, field_type: type[Any] | _SpecialForm, is_required: bool, description: str
|
||||
) -> tuple:
|
||||
) -> tuple[type[Any] | _SpecialForm, Any]:
|
||||
"""Create Pydantic field definition based on type and requirement."""
|
||||
if is_required:
|
||||
return (field_type, Field(description=description))
|
||||
@@ -232,7 +232,7 @@ class EnterpriseActionTool(BaseTool):
|
||||
return any(t.get("type") == "null" for t in schema["anyOf"])
|
||||
return schema.get("type") == "null"
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
"""Execute the specific enterprise action with validated parameters."""
|
||||
try:
|
||||
cleaned_kwargs = {}
|
||||
@@ -280,8 +280,8 @@ class EnterpriseActionKitToolAdapter:
|
||||
):
|
||||
"""Initialize the adapter with an enterprise action token."""
|
||||
self._set_enterprise_action_token(enterprise_action_token)
|
||||
self._actions_schema = {} # type: ignore[var-annotated]
|
||||
self._tools = None
|
||||
self._actions_schema: dict[str, Any] = {}
|
||||
self._tools: list[BaseTool] | None = None
|
||||
self.enterprise_api_base_url = (
|
||||
enterprise_api_base_url or get_enterprise_api_base_url()
|
||||
)
|
||||
@@ -293,7 +293,7 @@ class EnterpriseActionKitToolAdapter:
|
||||
self._create_tools()
|
||||
return self._tools or []
|
||||
|
||||
def _fetch_actions(self):
|
||||
def _fetch_actions(self) -> None:
|
||||
"""Fetch available actions from the API."""
|
||||
try:
|
||||
actions_url = f"{self.enterprise_api_base_url}/actions"
|
||||
@@ -379,9 +379,9 @@ class EnterpriseActionKitToolAdapter:
|
||||
|
||||
return descriptions
|
||||
|
||||
def _create_tools(self):
|
||||
def _create_tools(self) -> None:
|
||||
"""Create BaseTool instances for each action."""
|
||||
tools = []
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
for action_name, action_schema in self._actions_schema.items():
|
||||
function_details = action_schema.get("function", {})
|
||||
@@ -403,7 +403,7 @@ class EnterpriseActionKitToolAdapter:
|
||||
description=full_description,
|
||||
action_name=action_name,
|
||||
action_schema=action_schema,
|
||||
enterprise_action_token=self.enterprise_action_token,
|
||||
enterprise_action_token=self.enterprise_action_token or "",
|
||||
enterprise_api_base_url=self.enterprise_api_base_url,
|
||||
)
|
||||
|
||||
@@ -411,7 +411,7 @@ class EnterpriseActionKitToolAdapter:
|
||||
|
||||
self._tools = tools
|
||||
|
||||
def _set_enterprise_action_token(self, enterprise_action_token: str | None):
|
||||
def _set_enterprise_action_token(self, enterprise_action_token: str | None) -> None:
|
||||
if enterprise_action_token and not enterprise_action_token.startswith("PK_"):
|
||||
warnings.warn(
|
||||
"Legacy token detected, please consider using the new Enterprise Action Auth token. Check out our docs for more information https://docs.crewai.com/en/enterprise/features/integrations.",
|
||||
@@ -423,10 +423,15 @@ class EnterpriseActionKitToolAdapter:
|
||||
"CREWAI_ENTERPRISE_TOOLS_TOKEN"
|
||||
)
|
||||
|
||||
self.enterprise_action_token = token
|
||||
self.enterprise_action_token: str | None = token
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> list[BaseTool]:
|
||||
return self.tools()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@@ -5,20 +5,19 @@ from typing import Any
|
||||
|
||||
from crewai.utilities.lock_store import lock as store_lock
|
||||
from lancedb import ( # type: ignore[import-untyped]
|
||||
DBConnection as LanceDBConnection,
|
||||
connect as lancedb_connect,
|
||||
)
|
||||
from lancedb.table import Table as LanceDBTable # type: ignore[import-untyped]
|
||||
from openai import Client as OpenAIClient
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from crewai_tools.tools.rag.rag_tool import Adapter
|
||||
|
||||
|
||||
def _default_embedding_function():
|
||||
def _default_embedding_function() -> Callable[[list[str]], list[list[float]]]:
|
||||
"""Create a default embedding function using OpenAI."""
|
||||
client = OpenAIClient()
|
||||
|
||||
def _embedding_function(input):
|
||||
def _embedding_function(input: list[str]) -> list[list[float]]:
|
||||
rs = client.embeddings.create(input=input, model="text-embedding-ada-002")
|
||||
return [record.embedding for record in rs.data]
|
||||
|
||||
@@ -28,13 +27,15 @@ def _default_embedding_function():
|
||||
class LanceDBAdapter(Adapter):
|
||||
uri: str | Path
|
||||
table_name: str
|
||||
embedding_function: Callable = Field(default_factory=_default_embedding_function)
|
||||
embedding_function: Callable[[list[str]], list[list[float]]] = Field(
|
||||
default_factory=_default_embedding_function
|
||||
)
|
||||
top_k: int = 3
|
||||
vector_column_name: str = "vector"
|
||||
text_column_name: str = "text"
|
||||
|
||||
_db: LanceDBConnection = PrivateAttr()
|
||||
_table: LanceDBTable = PrivateAttr()
|
||||
_db: Any = PrivateAttr()
|
||||
_table: Any = PrivateAttr()
|
||||
_lock_name: str = PrivateAttr(default="")
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
|
||||
@@ -12,7 +12,7 @@ class RAGAdapter(Adapter):
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
top_k: int = 5,
|
||||
embedding_api_key: str | None = None,
|
||||
**embedding_kwargs,
|
||||
**embedding_kwargs: Any,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from crewai.tools import BaseTool
|
||||
T = TypeVar("T", bound=BaseTool)
|
||||
|
||||
|
||||
class ToolCollection(list, Generic[T]):
|
||||
class ToolCollection(list[T], Generic[T]):
|
||||
"""A collection of tools that can be accessed by index or name.
|
||||
|
||||
This class extends the built-in list to provide dictionary-like
|
||||
@@ -34,7 +34,8 @@ class ToolCollection(list, Generic[T]):
|
||||
def __getitem__(self, key: int | str) -> T: # type: ignore[override]
|
||||
if isinstance(key, str):
|
||||
return self._name_cache[key.lower()]
|
||||
return super().__getitem__(key)
|
||||
result: T = super().__getitem__(key)
|
||||
return result
|
||||
|
||||
def append(self, tool: T) -> None:
|
||||
super().append(tool)
|
||||
@@ -54,7 +55,7 @@ class ToolCollection(list, Generic[T]):
|
||||
del self._name_cache[tool.name.lower()]
|
||||
|
||||
def pop(self, index: int = -1) -> T: # type: ignore[override]
|
||||
tool = super().pop(index)
|
||||
tool: T = super().pop(index)
|
||||
if tool.name.lower() in self._name_cache:
|
||||
del self._name_cache[tool.name.lower()]
|
||||
return tool
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Final, Literal
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import Field, create_model
|
||||
@@ -22,7 +22,7 @@ class ZapierActionTool(BaseTool):
|
||||
action_id: str = Field(description="Zapier action ID")
|
||||
api_key: str = Field(description="Zapier API key")
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> Any:
|
||||
"""Execute the Zapier action."""
|
||||
headers = {"x-api-key": self.api_key, "Content-Type": "application/json"}
|
||||
|
||||
@@ -64,9 +64,9 @@ class ZapierActionsAdapter:
|
||||
logger.error("Zapier Actions API key is required")
|
||||
raise ValueError("Zapier Actions API key is required")
|
||||
|
||||
def get_zapier_actions(self):
|
||||
def get_zapier_actions(self) -> Any:
|
||||
headers = {
|
||||
"x-api-key": self.api_key,
|
||||
"x-api-key": self.api_key or "",
|
||||
}
|
||||
response = requests.request(
|
||||
"GET",
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from dotenv import load_dotenv
|
||||
@@ -42,17 +43,17 @@ class BedrockInvokeAgentTool(BaseTool):
|
||||
enable_trace: bool = False,
|
||||
end_session: bool = False,
|
||||
description: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the BedrockInvokeAgentTool with agent configuration.
|
||||
|
||||
Args:
|
||||
agent_id (str): The unique identifier of the Bedrock agent
|
||||
agent_alias_id (str): The unique identifier of the agent alias
|
||||
session_id (str): The unique identifier of the session
|
||||
enable_trace (bool): Whether to enable trace for the agent invocation
|
||||
end_session (bool): Whether to end the session with the agent
|
||||
description (Optional[str]): Custom description for the tool
|
||||
agent_id: The unique identifier of the Bedrock agent.
|
||||
agent_alias_id: The unique identifier of the agent alias.
|
||||
session_id: The unique identifier of the session.
|
||||
enable_trace: Whether to enable trace for the agent invocation.
|
||||
end_session: Whether to end the session with the agent.
|
||||
description: Custom description for the tool.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -72,7 +73,7 @@ class BedrockInvokeAgentTool(BaseTool):
|
||||
# Validate parameters
|
||||
self._validate_parameters()
|
||||
|
||||
def _validate_parameters(self):
|
||||
def _validate_parameters(self) -> None:
|
||||
"""Validate the parameters according to AWS API requirements."""
|
||||
try:
|
||||
# Validate agent_id
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
@@ -82,7 +82,7 @@ class CurrentWebPageToolInput(BaseModel):
|
||||
class BrowserBaseTool(BaseTool):
|
||||
"""Base class for browser tools."""
|
||||
|
||||
def __init__(self, session_manager: BrowserSessionManager): # type: ignore[call-arg]
|
||||
def __init__(self, session_manager: BrowserSessionManager) -> None:
|
||||
"""Initialize with a session manager."""
|
||||
super().__init__() # type: ignore[call-arg]
|
||||
self._session_manager = session_manager
|
||||
@@ -90,16 +90,16 @@ class BrowserBaseTool(BaseTool):
|
||||
if self._is_in_asyncio_loop() and hasattr(self, "_arun"):
|
||||
self._original_run = self._run
|
||||
|
||||
# Override _run to use _arun when in an asyncio loop
|
||||
def patched_run(*args, **kwargs):
|
||||
def patched_run(*args: Any, **kwargs: Any) -> str:
|
||||
try:
|
||||
import nest_asyncio # type: ignore[import-untyped]
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
nest_asyncio.apply(loop)
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
result: str = asyncio.get_event_loop().run_until_complete(
|
||||
self._arun(*args, **kwargs)
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error in patched _run: {e!s}"
|
||||
|
||||
@@ -132,7 +132,7 @@ class NavigateTool(BrowserBaseTool):
|
||||
description: str = "Navigate a browser to the specified URL"
|
||||
args_schema: type[BaseModel] = NavigateToolInput
|
||||
|
||||
def _run(self, url: str, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, url: str, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Get page for this thread
|
||||
@@ -150,7 +150,7 @@ class NavigateTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error navigating to {url}: {e!s}"
|
||||
|
||||
async def _arun(self, url: str, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(self, url: str, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Get page for this thread
|
||||
@@ -188,7 +188,7 @@ class ClickTool(BrowserBaseTool):
|
||||
return selector
|
||||
return f"{selector} >> visible=1"
|
||||
|
||||
def _run(self, selector: str, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, selector: str, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -213,7 +213,9 @@ class ClickTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error clicking on element: {e!s}"
|
||||
|
||||
async def _arun(self, selector: str, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(
|
||||
self, selector: str, thread_id: str = "default", **kwargs: Any
|
||||
) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -246,7 +248,7 @@ class NavigateBackTool(BrowserBaseTool):
|
||||
description: str = "Navigate back to the previous page"
|
||||
args_schema: type[BaseModel] = NavigateBackToolInput
|
||||
|
||||
def _run(self, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -261,7 +263,7 @@ class NavigateBackTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error navigating back: {e!s}"
|
||||
|
||||
async def _arun(self, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -284,7 +286,7 @@ class ExtractTextTool(BrowserBaseTool):
|
||||
description: str = "Extract all the text on the current webpage"
|
||||
args_schema: type[BaseModel] = ExtractTextToolInput
|
||||
|
||||
def _run(self, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Import BeautifulSoup
|
||||
@@ -306,7 +308,7 @@ class ExtractTextTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error extracting text: {e!s}"
|
||||
|
||||
async def _arun(self, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Import BeautifulSoup
|
||||
@@ -336,12 +338,12 @@ class ExtractHyperlinksTool(BrowserBaseTool):
|
||||
description: str = "Extract all hyperlinks on the current webpage"
|
||||
args_schema: type[BaseModel] = ExtractHyperlinksToolInput
|
||||
|
||||
def _run(self, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Import BeautifulSoup
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
except ImportError:
|
||||
return (
|
||||
"The 'beautifulsoup4' package is required to use this tool."
|
||||
@@ -356,9 +358,10 @@ class ExtractHyperlinksTool(BrowserBaseTool):
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
links = []
|
||||
for link in soup.find_all("a", href=True):
|
||||
text = link.get_text().strip()
|
||||
href = link["href"]
|
||||
if href.startswith(("http", "https")): # type: ignore[union-attr]
|
||||
tag = cast(Tag, link)
|
||||
text = tag.get_text().strip()
|
||||
href = str(tag.get("href", ""))
|
||||
if href.startswith(("http", "https")):
|
||||
links.append({"text": text, "url": href})
|
||||
|
||||
if not links:
|
||||
@@ -368,12 +371,12 @@ class ExtractHyperlinksTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error extracting hyperlinks: {e!s}"
|
||||
|
||||
async def _arun(self, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Import BeautifulSoup
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
except ImportError:
|
||||
return (
|
||||
"The 'beautifulsoup4' package is required to use this tool."
|
||||
@@ -388,9 +391,10 @@ class ExtractHyperlinksTool(BrowserBaseTool):
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
links = []
|
||||
for link in soup.find_all("a", href=True):
|
||||
text = link.get_text().strip()
|
||||
href = link["href"]
|
||||
if href.startswith(("http", "https")): # type: ignore[union-attr]
|
||||
tag = cast(Tag, link)
|
||||
text = tag.get_text().strip()
|
||||
href = str(tag.get("href", ""))
|
||||
if href.startswith(("http", "https")):
|
||||
links.append({"text": text, "url": href})
|
||||
|
||||
if not links:
|
||||
@@ -408,7 +412,7 @@ class GetElementsTool(BrowserBaseTool):
|
||||
description: str = "Get elements from the webpage using a CSS selector"
|
||||
args_schema: type[BaseModel] = GetElementsToolInput
|
||||
|
||||
def _run(self, selector: str, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, selector: str, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -428,7 +432,9 @@ class GetElementsTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error getting elements: {e!s}"
|
||||
|
||||
async def _arun(self, selector: str, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(
|
||||
self, selector: str, thread_id: str = "default", **kwargs: Any
|
||||
) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -456,7 +462,7 @@ class CurrentWebPageTool(BrowserBaseTool):
|
||||
description: str = "Get information about the current webpage"
|
||||
args_schema: type[BaseModel] = CurrentWebPageToolInput
|
||||
|
||||
def _run(self, thread_id: str = "default", **kwargs) -> str:
|
||||
def _run(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the sync tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -469,7 +475,7 @@ class CurrentWebPageTool(BrowserBaseTool):
|
||||
except Exception as e:
|
||||
return f"Error getting current webpage info: {e!s}"
|
||||
|
||||
async def _arun(self, thread_id: str = "default", **kwargs) -> str:
|
||||
async def _arun(self, thread_id: str = "default", **kwargs: Any) -> str:
|
||||
"""Use the async tool."""
|
||||
try:
|
||||
# Get the current page
|
||||
@@ -535,7 +541,7 @@ class BrowserToolkit:
|
||||
self._nest_current_loop()
|
||||
self._setup_tools()
|
||||
|
||||
def _nest_current_loop(self):
|
||||
def _nest_current_loop(self) -> None:
|
||||
"""Apply nest_asyncio if we're in an asyncio loop."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_output_from_stream(response):
|
||||
def extract_output_from_stream(response: dict[str, Any]) -> str:
|
||||
"""Extract output from code interpreter response stream.
|
||||
|
||||
Args:
|
||||
@@ -143,8 +143,8 @@ class ExecuteCodeTool(BaseTool):
|
||||
args_schema: type[BaseModel] = ExecuteCodeInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(
|
||||
@@ -198,8 +198,8 @@ class ExecuteCommandTool(BaseTool):
|
||||
args_schema: type[BaseModel] = ExecuteCommandInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, command: str, thread_id: str = "default") -> str:
|
||||
@@ -231,8 +231,8 @@ class ReadFilesTool(BaseTool):
|
||||
args_schema: type[BaseModel] = ReadFilesInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, paths: list[str], thread_id: str = "default") -> str:
|
||||
@@ -264,8 +264,8 @@ class ListFilesTool(BaseTool):
|
||||
args_schema: type[BaseModel] = ListFilesInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, directory_path: str = "", thread_id: str = "default") -> str:
|
||||
@@ -297,8 +297,8 @@ class DeleteFilesTool(BaseTool):
|
||||
args_schema: type[BaseModel] = DeleteFilesInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, paths: list[str], thread_id: str = "default") -> str:
|
||||
@@ -330,8 +330,8 @@ class WriteFilesTool(BaseTool):
|
||||
args_schema: type[BaseModel] = WriteFilesInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, files: list[dict[str, str]], thread_id: str = "default") -> str:
|
||||
@@ -365,8 +365,8 @@ class StartCommandTool(BaseTool):
|
||||
args_schema: type[BaseModel] = StartCommandInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, command: str, thread_id: str = "default") -> str:
|
||||
@@ -398,8 +398,8 @@ class GetTaskTool(BaseTool):
|
||||
args_schema: type[BaseModel] = GetTaskInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, task_id: str, thread_id: str = "default") -> str:
|
||||
@@ -431,8 +431,8 @@ class StopTaskTool(BaseTool):
|
||||
args_schema: type[BaseModel] = StopTaskInput
|
||||
toolkit: Any = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, toolkit):
|
||||
super().__init__()
|
||||
def __init__(self, toolkit: CodeInterpreterToolkit, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.toolkit = toolkit
|
||||
|
||||
def _run(self, task_id: str, thread_id: str = "default") -> str:
|
||||
|
||||
@@ -44,16 +44,16 @@ class BedrockKBRetrieverTool(BaseTool):
|
||||
retrieval_configuration: dict[str, Any] | None = None,
|
||||
guardrail_configuration: dict[str, Any] | None = None,
|
||||
next_token: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the BedrockKBRetrieverTool with knowledge base configuration.
|
||||
|
||||
Args:
|
||||
knowledge_base_id (str): The unique identifier of the knowledge base to query
|
||||
number_of_results (Optional[int], optional): The maximum number of results to return. Defaults to 5.
|
||||
retrieval_configuration (Optional[Dict[str, Any]], optional): Configurations for the knowledge base query and retrieval process. Defaults to None.
|
||||
guardrail_configuration (Optional[Dict[str, Any]], optional): Guardrail settings. Defaults to None.
|
||||
next_token (Optional[str], optional): Token for retrieving the next batch of results. Defaults to None.
|
||||
knowledge_base_id: The unique identifier of the knowledge base to query.
|
||||
number_of_results: The maximum number of results to return.
|
||||
retrieval_configuration: Configurations for the knowledge base query and retrieval process.
|
||||
guardrail_configuration: Guardrail settings.
|
||||
next_token: Token for retrieving the next batch of results.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -89,7 +89,7 @@ class BedrockKBRetrieverTool(BaseTool):
|
||||
|
||||
return {"vectorSearchConfiguration": vector_search_config}
|
||||
|
||||
def _validate_parameters(self):
|
||||
def _validate_parameters(self) -> None:
|
||||
"""Validate the parameters according to AWS API requirements."""
|
||||
try:
|
||||
# Validate knowledge_base_id
|
||||
|
||||
@@ -39,11 +39,12 @@ class S3ReaderTool(BaseTool):
|
||||
|
||||
# Read file content from S3
|
||||
response = s3.get_object(Bucket=bucket_name, Key=object_key)
|
||||
return response["Body"].read().decode("utf-8")
|
||||
result: str = response["Body"].read().decode("utf-8")
|
||||
return result
|
||||
|
||||
except ClientError as e:
|
||||
return f"Error reading file from S3: {e!s}"
|
||||
|
||||
def _parse_s3_path(self, file_path: str) -> tuple:
|
||||
def _parse_s3_path(self, file_path: str) -> tuple[str, str]:
|
||||
parts = file_path.replace("s3://", "").split("/", 1)
|
||||
return parts[0], parts[1]
|
||||
|
||||
@@ -45,6 +45,6 @@ class S3WriterTool(BaseTool):
|
||||
except ClientError as e:
|
||||
return f"Error writing file to S3: {e!s}"
|
||||
|
||||
def _parse_s3_path(self, file_path: str) -> tuple:
|
||||
def _parse_s3_path(self, file_path: str) -> tuple[str, str]:
|
||||
parts = file_path.replace("s3://", "").split("/", 1)
|
||||
return parts[0], parts[1]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
import inspect
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from crewai.tools.base_tool import BaseTool, EnvVar
|
||||
from pydantic import BaseModel
|
||||
@@ -115,7 +115,8 @@ class ToolSpecExtractor:
|
||||
default_value = field.default
|
||||
if default_value is PydanticUndefined or default_value is None:
|
||||
if field.default_factory:
|
||||
return field.default_factory()
|
||||
factory = cast(Callable[[], Any], field.default_factory)
|
||||
return factory()
|
||||
return None
|
||||
|
||||
return default_value
|
||||
@@ -153,21 +154,19 @@ class ToolSpecExtractor:
|
||||
|
||||
return default_value
|
||||
|
||||
# Dynamically computed from BaseTool so that any future fields or
|
||||
# computed_fields added to BaseTool are automatically excluded from
|
||||
# the generated spec — no hardcoded denylist to maintain.
|
||||
# ``package_dependencies`` is not a BaseTool field but is extracted
|
||||
# into its own top-level key, so it's also excluded from init_params.
|
||||
_BASE_TOOL_FIELDS: set[str] = (
|
||||
set(BaseTool.model_fields)
|
||||
| set(BaseTool.model_computed_fields)
|
||||
| {"package_dependencies"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_init_params(tool_class: type[BaseTool]) -> dict[str, Any]:
|
||||
ignored_init_params = [
|
||||
"name",
|
||||
"description",
|
||||
"env_vars",
|
||||
"args_schema",
|
||||
"description_updated",
|
||||
"cache_function",
|
||||
"result_as_answer",
|
||||
"max_usage_count",
|
||||
"current_usage_count",
|
||||
"package_dependencies",
|
||||
]
|
||||
|
||||
json_schema = tool_class.model_json_schema(
|
||||
schema_generator=SchemaGenerator, mode="serialization"
|
||||
)
|
||||
@@ -175,8 +174,14 @@ class ToolSpecExtractor:
|
||||
json_schema["properties"] = {
|
||||
key: value
|
||||
for key, value in json_schema["properties"].items()
|
||||
if key not in ignored_init_params
|
||||
if key not in ToolSpecExtractor._BASE_TOOL_FIELDS
|
||||
}
|
||||
if "required" in json_schema:
|
||||
json_schema["required"] = [
|
||||
key
|
||||
for key in json_schema["required"]
|
||||
if key not in ToolSpecExtractor._BASE_TOOL_FIELDS
|
||||
]
|
||||
return json_schema
|
||||
|
||||
def save_to_json(self, output_path: str) -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from crewai_tools.rag.core import RAG, EmbeddingService
|
||||
from crewai_tools.rag.core import RAG
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
from crewai_tools.rag.embedding_service import EmbeddingService
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -21,7 +21,7 @@ class BaseLoader(ABC):
|
||||
self.config = config or {}
|
||||
|
||||
@abstractmethod
|
||||
def load(self, content: SourceContent, **kwargs) -> LoaderResult: ...
|
||||
def load(self, content: SourceContent, **kwargs: Any) -> LoaderResult: ...
|
||||
|
||||
@staticmethod
|
||||
def generate_doc_id(
|
||||
|
||||
@@ -77,7 +77,7 @@ class RAG(Adapter):
|
||||
|
||||
super().model_post_init(__context)
|
||||
|
||||
def add(
|
||||
def add( # type: ignore[override]
|
||||
self,
|
||||
content: str | Path,
|
||||
data_type: str | DataType | None = None,
|
||||
|
||||
@@ -109,7 +109,7 @@ class DataTypes:
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
url = urlparse(content)
|
||||
is_url = bool(url.scheme and url.netloc) or url.scheme == "file"
|
||||
is_url = bool(url.scheme in ("http", "https") and url.netloc)
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from crewai.rag.core.base_embeddings_callable import EmbeddingFunction
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -81,7 +82,7 @@ class EmbeddingService:
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._embedding_function = None
|
||||
self._embedding_function: EmbeddingFunction[Any] | None = None
|
||||
self._initialize_embedding_function()
|
||||
|
||||
@staticmethod
|
||||
@@ -107,7 +108,7 @@ class EmbeddingService:
|
||||
return os.getenv(env_key)
|
||||
return None
|
||||
|
||||
def _initialize_embedding_function(self):
|
||||
def _initialize_embedding_function(self) -> None:
|
||||
"""Initialize the embedding function using CrewAI's factory."""
|
||||
try:
|
||||
from crewai.rag.embeddings.factory import build_embedder
|
||||
@@ -264,7 +265,7 @@ class EmbeddingService:
|
||||
try:
|
||||
# Use ChromaDB's embedding function interface
|
||||
embeddings = self._embedding_function([text]) # type: ignore
|
||||
return embeddings[0] if embeddings else []
|
||||
return list(embeddings[0]) if embeddings else []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating embedding for text: {e}")
|
||||
@@ -294,12 +295,12 @@ class EmbeddingService:
|
||||
|
||||
try:
|
||||
# Process in batches to avoid API limits
|
||||
all_embeddings = []
|
||||
all_embeddings: list[list[float]] = []
|
||||
|
||||
for i in range(0, len(valid_texts), self.config.batch_size):
|
||||
batch = valid_texts[i : i + self.config.batch_size]
|
||||
batch_embeddings = self._embedding_function(batch) # type: ignore
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
all_embeddings.extend(list(e) for e in batch_embeddings)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import csv
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.loaders.utils import load_from_url
|
||||
@@ -7,7 +8,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
|
||||
|
||||
class CSVLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
source_ref = source_content.source_ref
|
||||
|
||||
content_str = source_content.source
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.source_content import SourceContent
|
||||
|
||||
|
||||
class DirectoryLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load and process all files from a directory recursively.
|
||||
|
||||
Args:
|
||||
@@ -32,7 +33,7 @@ class DirectoryLoader(BaseLoader):
|
||||
|
||||
return self._process_directory(source_ref, kwargs)
|
||||
|
||||
def _process_directory(self, dir_path: str, kwargs: dict) -> LoaderResult:
|
||||
def _process_directory(self, dir_path: str, kwargs: dict[str, Any]) -> LoaderResult:
|
||||
recursive: bool = kwargs.get("recursive", True)
|
||||
include_extensions: list[str] | None = kwargs.get("include_extensions", None)
|
||||
exclude_extensions: list[str] | None = kwargs.get("exclude_extensions", None)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Documentation site loader."""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
import requests
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
@@ -12,7 +13,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
class DocsSiteLoader(BaseLoader):
|
||||
"""Loader for documentation websites."""
|
||||
|
||||
def load(self, source: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load content from a documentation site.
|
||||
|
||||
Args:
|
||||
@@ -53,7 +54,9 @@ class DocsSiteLoader(BaseLoader):
|
||||
break
|
||||
|
||||
if not main_content:
|
||||
main_content = soup.find("body")
|
||||
body = soup.find("body")
|
||||
if isinstance(body, Tag):
|
||||
main_content = body
|
||||
|
||||
if not main_content:
|
||||
raise ValueError(
|
||||
@@ -66,6 +69,8 @@ class DocsSiteLoader(BaseLoader):
|
||||
if headings:
|
||||
text_parts.append("Table of Contents:")
|
||||
for heading in headings[:15]:
|
||||
if not isinstance(heading, Tag):
|
||||
continue
|
||||
level = int(heading.name[1])
|
||||
indent = " " * (level - 1)
|
||||
text_parts.append(f"{indent}- {heading.get_text(strip=True)}")
|
||||
@@ -81,6 +86,8 @@ class DocsSiteLoader(BaseLoader):
|
||||
if nav:
|
||||
links = nav.find_all("a", href=True)
|
||||
for link in links[:20]:
|
||||
if not isinstance(link, Tag):
|
||||
continue
|
||||
href = link.get("href", "")
|
||||
if isinstance(href, str) and not href.startswith(
|
||||
("http://", "https://", "mailto:", "#")
|
||||
|
||||
@@ -9,7 +9,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
|
||||
|
||||
class DOCXLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
try:
|
||||
from docx import Document as DocxDocument
|
||||
except ImportError as e:
|
||||
@@ -33,7 +33,7 @@ class DOCXLoader(BaseLoader):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _download_from_url(url: str, kwargs: dict) -> str:
|
||||
def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
|
||||
headers = kwargs.get(
|
||||
"headers",
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""GitHub repository content loader."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from github import Github, GithubException
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
@@ -9,7 +11,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
class GithubLoader(BaseLoader):
|
||||
"""Loader for GitHub repository content."""
|
||||
|
||||
def load(self, source: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load content from a GitHub repository.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.loaders.utils import load_from_url
|
||||
@@ -6,7 +7,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
|
||||
|
||||
class JSONLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
source_ref = source_content.source_ref
|
||||
content = source_content.source
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.loaders.utils import load_from_url
|
||||
@@ -15,7 +15,7 @@ _EXTRA_NEWLINES_PATTERN: Final[re.Pattern[str]] = re.compile(r"\n\s*\n\s*\n")
|
||||
|
||||
|
||||
class MDXLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
source_ref = source_content.source_ref
|
||||
content = source_content.source
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.source_content import SourceContent
|
||||
@@ -23,22 +25,34 @@ class PDFLoader(BaseLoader):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _download_pdf(url: str) -> bytes:
|
||||
"""Download PDF content from a URL.
|
||||
def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
|
||||
"""Download PDF from a URL to a temporary file and return its path.
|
||||
|
||||
Args:
|
||||
url: The URL to download from.
|
||||
kwargs: Optional dict that may contain custom headers.
|
||||
|
||||
Returns:
|
||||
The PDF content as bytes.
|
||||
Path to the temporary file containing the PDF.
|
||||
|
||||
Raises:
|
||||
ValueError: If the download fails.
|
||||
"""
|
||||
headers = kwargs.get(
|
||||
"headers",
|
||||
{
|
||||
"Accept": "application/pdf",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; crewai-tools PDFLoader)",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
|
||||
return cast(bytes, response.read())
|
||||
response = requests.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
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
|
||||
|
||||
@@ -80,8 +94,8 @@ class PDFLoader(BaseLoader):
|
||||
|
||||
try:
|
||||
if is_url:
|
||||
pdf_bytes = self._download_pdf(file_path)
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
local_path = self._download_from_url(file_path, kwargs)
|
||||
doc = pymupdf.open(local_path)
|
||||
else:
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"PDF file not found: {file_path}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PostgreSQL database loader."""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from psycopg2 import Error, connect
|
||||
@@ -12,7 +13,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
class PostgresLoader(BaseLoader):
|
||||
"""Loader for PostgreSQL database content."""
|
||||
|
||||
def load(self, source: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load content from a PostgreSQL database table.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
|
||||
from crewai_tools.rag.source_content import SourceContent
|
||||
|
||||
|
||||
class TextFileLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
source_ref = source_content.source_ref
|
||||
if not source_content.path_exists():
|
||||
raise FileNotFoundError(
|
||||
@@ -21,7 +23,7 @@ class TextFileLoader(BaseLoader):
|
||||
|
||||
|
||||
class TextLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
return LoaderResult(
|
||||
content=source_content.source,
|
||||
source=source_content.source_ref,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Utility functions for RAG loaders."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_from_url(
|
||||
url: str, kwargs: dict, accept_header: str = "*/*", loader_name: str = "Loader"
|
||||
url: str,
|
||||
kwargs: dict[str, Any],
|
||||
accept_header: str = "*/*",
|
||||
loader_name: str = "Loader",
|
||||
) -> str:
|
||||
"""Load content from a URL.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
@@ -13,7 +13,7 @@ _NEWLINE_PATTERN: Final[re.Pattern[str]] = re.compile(r"\s+\n\s+")
|
||||
|
||||
|
||||
class WebPageLoader(BaseLoader):
|
||||
def load(self, source_content: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source_content: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
url = source_content.source
|
||||
headers = kwargs.get(
|
||||
"headers",
|
||||
|
||||
@@ -10,7 +10,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
class YoutubeChannelLoader(BaseLoader):
|
||||
"""Loader for YouTube channels."""
|
||||
|
||||
def load(self, source: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load and extract content from a YouTube channel.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -11,7 +11,7 @@ from crewai_tools.rag.source_content import SourceContent
|
||||
class YoutubeVideoLoader(BaseLoader):
|
||||
"""Loader for YouTube videos."""
|
||||
|
||||
def load(self, source: SourceContent, **kwargs) -> LoaderResult: # type: ignore[override]
|
||||
def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: ignore[override]
|
||||
"""Load and extract transcript from a YouTube video.
|
||||
|
||||
Args:
|
||||
|
||||
205
lib/crewai-tools/src/crewai_tools/security/safe_path.py
Normal file
205
lib/crewai-tools/src/crewai_tools/security/safe_path.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Path and URL validation utilities for crewai-tools.
|
||||
|
||||
Provides validation for file paths and URLs to prevent unauthorized
|
||||
file access and server-side request forgery (SSRF) when tools accept
|
||||
user-controlled or LLM-controlled inputs at runtime.
|
||||
|
||||
Set CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true to bypass validation (not
|
||||
recommended for production).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
|
||||
|
||||
|
||||
def _is_escape_hatch_enabled() -> bool:
|
||||
"""Check if the unsafe paths escape hatch is enabled."""
|
||||
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File path validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_file_path(path: str, base_dir: str | None = None) -> str:
|
||||
"""Validate that a file path is safe to read.
|
||||
|
||||
Resolves symlinks and ``..`` components, then checks that the resolved
|
||||
path falls within *base_dir* (defaults to the current working directory).
|
||||
|
||||
Args:
|
||||
path: The file path to validate.
|
||||
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
|
||||
|
||||
Returns:
|
||||
The resolved, validated absolute path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path escapes the allowed directory.
|
||||
"""
|
||||
if _is_escape_hatch_enabled():
|
||||
logger.warning(
|
||||
"%s is enabled — skipping file path validation for: %s",
|
||||
_UNSAFE_PATHS_ENV,
|
||||
path,
|
||||
)
|
||||
return os.path.realpath(path)
|
||||
|
||||
if base_dir is None:
|
||||
base_dir = os.getcwd()
|
||||
|
||||
resolved_base = os.path.realpath(base_dir)
|
||||
resolved_path = os.path.realpath(
|
||||
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
|
||||
)
|
||||
|
||||
# Ensure the resolved path is within the base directory.
|
||||
# When resolved_base already ends with a separator (e.g. the filesystem
|
||||
# root "/"), appending os.sep would double it ("//"), so use the base
|
||||
# as-is in that case.
|
||||
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
|
||||
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
|
||||
raise ValueError(
|
||||
f"Path '{path}' resolves to '{resolved_path}' which is outside "
|
||||
f"the allowed directory '{resolved_base}'. "
|
||||
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
|
||||
)
|
||||
|
||||
return resolved_path
|
||||
|
||||
|
||||
def validate_directory_path(path: str, base_dir: str | None = None) -> str:
|
||||
"""Validate that a directory path is safe to read.
|
||||
|
||||
Same as :func:`validate_file_path` but also checks that the path
|
||||
is an existing directory.
|
||||
|
||||
Args:
|
||||
path: The directory path to validate.
|
||||
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
|
||||
|
||||
Returns:
|
||||
The resolved, validated absolute path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path escapes the allowed directory or is not a directory.
|
||||
"""
|
||||
validated = validate_file_path(path, base_dir)
|
||||
if not os.path.isdir(validated):
|
||||
raise ValueError(f"Path '{validated}' is not a directory.")
|
||||
return validated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Private and reserved IP ranges that should not be accessed
|
||||
_BLOCKED_IPV4_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"), # Link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/32"),
|
||||
]
|
||||
|
||||
_BLOCKED_IPV6_NETWORKS = [
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("::/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # Unique local addresses
|
||||
ipaddress.ip_network("fe80::/10"), # Link-local IPv6
|
||||
]
|
||||
|
||||
|
||||
def _is_private_or_reserved(ip_str: str) -> bool:
|
||||
"""Check if an IP address is private, reserved, or otherwise unsafe."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
# Unwrap IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) to IPv4
|
||||
# so they are only checked against IPv4 networks (avoids TypeError when
|
||||
# an IPv4Address is compared against an IPv6Network).
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
||||
addr = addr.ipv4_mapped
|
||||
networks = (
|
||||
_BLOCKED_IPV4_NETWORKS
|
||||
if isinstance(addr, ipaddress.IPv4Address)
|
||||
else _BLOCKED_IPV6_NETWORKS
|
||||
)
|
||||
return any(addr in network for network in networks)
|
||||
except ValueError:
|
||||
return True # If we can't parse, block it
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Validate that a URL is safe to fetch.
|
||||
|
||||
Blocks ``file://`` scheme entirely. For ``http``/``https``, resolves
|
||||
DNS and checks that the target IP is not private or reserved (prevents
|
||||
SSRF to internal services and cloud metadata endpoints).
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Returns:
|
||||
The validated URL string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL uses a blocked scheme or resolves to a
|
||||
private/reserved IP address.
|
||||
"""
|
||||
if _is_escape_hatch_enabled():
|
||||
logger.warning(
|
||||
"%s is enabled — skipping URL validation for: %s",
|
||||
_UNSAFE_PATHS_ENV,
|
||||
url,
|
||||
)
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Block file:// scheme
|
||||
if parsed.scheme == "file":
|
||||
raise ValueError(
|
||||
f"file:// URLs are not allowed: '{url}'. "
|
||||
f"Use a file path instead, or set {_UNSAFE_PATHS_ENV}=true to bypass."
|
||||
)
|
||||
|
||||
# Only allow http and https
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(
|
||||
f"URL scheme '{parsed.scheme}' is not allowed. Only http and https are supported."
|
||||
)
|
||||
|
||||
if not parsed.hostname:
|
||||
raise ValueError(f"URL has no hostname: '{url}'")
|
||||
|
||||
# Resolve DNS and check IPs
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(
|
||||
parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError(f"Could not resolve hostname: '{parsed.hostname}'") from exc
|
||||
|
||||
for _family, _, _, _, sockaddr in addrinfos:
|
||||
ip_str = str(sockaddr[0])
|
||||
if _is_private_or_reserved(ip_str):
|
||||
raise ValueError(
|
||||
f"URL '{url}' resolves to private/reserved IP {ip_str}. "
|
||||
f"Access to internal networks is not allowed. "
|
||||
f"Set {_UNSAFE_PATHS_ENV}=true to bypass."
|
||||
)
|
||||
|
||||
return url
|
||||
@@ -24,9 +24,6 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import (
|
||||
from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import (
|
||||
CodeDocsSearchTool,
|
||||
)
|
||||
from crewai_tools.tools.code_interpreter_tool.code_interpreter_tool import (
|
||||
CodeInterpreterTool,
|
||||
)
|
||||
from crewai_tools.tools.composio_tool.composio_tool import ComposioTool
|
||||
from crewai_tools.tools.contextualai_create_agent_tool.contextual_create_agent_tool import (
|
||||
ContextualAICreateAgentTool,
|
||||
@@ -210,7 +207,6 @@ __all__ = [
|
||||
"BrowserbaseLoadTool",
|
||||
"CSVSearchTool",
|
||||
"CodeDocsSearchTool",
|
||||
"CodeInterpreterTool",
|
||||
"ComposioTool",
|
||||
"ContextualAICreateAgentTool",
|
||||
"ContextualAIParseTool",
|
||||
|
||||
@@ -42,7 +42,7 @@ class AIMindTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str | None = None, **kwargs):
|
||||
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key or os.getenv("MINDS_API_KEY")
|
||||
if not self.api_key:
|
||||
@@ -51,8 +51,10 @@ class AIMindTool(BaseTool):
|
||||
)
|
||||
|
||||
try:
|
||||
from minds.client import Client # type: ignore
|
||||
from minds.datasources import DatabaseConfig # type: ignore
|
||||
from minds.client import Client # type: ignore[import-not-found]
|
||||
from minds.datasources import ( # type: ignore[import-not-found]
|
||||
DatabaseConfig,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"`minds_sdk` package not found, please run `pip install minds-sdk`"
|
||||
@@ -81,7 +83,7 @@ class AIMindTool(BaseTool):
|
||||
|
||||
self.mind_name = mind.name
|
||||
|
||||
def _run(self, query: str):
|
||||
def _run(self, query: str) -> str | None:
|
||||
# Run the query on the AI-Mind.
|
||||
# The Minds API is OpenAI compatible and therefore, the OpenAI client can be used.
|
||||
openai_client = OpenAI(
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -75,7 +75,9 @@ class ArxivPaperTool(BaseTool):
|
||||
logger.error(f"ArxivTool Error: {e!s}")
|
||||
return f"Failed to fetch or download Arxiv papers: {e!s}"
|
||||
|
||||
def fetch_arxiv_data(self, search_query: str, max_results: int) -> list[dict]:
|
||||
def fetch_arxiv_data(
|
||||
self, search_query: str, max_results: int
|
||||
) -> list[dict[str, Any]]:
|
||||
api_url = f"{self.BASE_API_URL}?search_query={urllib.parse.quote(search_query)}&start=0&max_results={max_results}"
|
||||
logger.info(f"Fetching data from Arxiv API: {api_url}")
|
||||
|
||||
@@ -135,7 +137,7 @@ class ArxivPaperTool(BaseTool):
|
||||
return href
|
||||
return None
|
||||
|
||||
def _format_paper_result(self, paper: dict) -> str:
|
||||
def _format_paper_result(self, paper: dict[str, Any]) -> str:
|
||||
summary = (
|
||||
(paper["summary"][: self.SUMMARY_TRUNCATE_LENGTH] + "...")
|
||||
if len(paper["summary"]) > self.SUMMARY_TRUNCATE_LENGTH
|
||||
@@ -156,7 +158,7 @@ class ArxivPaperTool(BaseTool):
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
return save_path
|
||||
|
||||
def download_pdf(self, pdf_url: str, save_path: str):
|
||||
def download_pdf(self, pdf_url: str, save_path: str) -> None:
|
||||
try:
|
||||
logger.info(f"Downloading PDF from {pdf_url} to {save_path}")
|
||||
urllib.request.urlretrieve(pdf_url, str(save_path)) # noqa: S310
|
||||
|
||||
@@ -138,7 +138,7 @@ class BraveSearchToolBase(BaseTool, ABC):
|
||||
self._rate_limit_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
def api_key(self) -> str | None:
|
||||
return self._api_key
|
||||
|
||||
@property
|
||||
@@ -214,7 +214,8 @@ class BraveSearchToolBase(BaseTool, ABC):
|
||||
# Response was OK, return the JSON body
|
||||
if resp.ok:
|
||||
try:
|
||||
return resp.json()
|
||||
result: dict[str, Any] = resp.json()
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"Brave Search API returned invalid JSON (HTTP {resp.status_code}): {exc}"
|
||||
@@ -239,9 +240,9 @@ class BraveSearchToolBase(BaseTool, ABC):
|
||||
# (e.g., 422 Unprocessable Entity, 400 Bad Request (OPTION_NOT_IN_PLAN))
|
||||
_raise_for_error(resp)
|
||||
|
||||
# All retries exhausted
|
||||
_raise_for_error(last_resp or resp) # type: ignore[possibly-undefined]
|
||||
return {} # unreachable (here to satisfy the type checker and linter)
|
||||
# All retries exhausted — last_resp is always set when we reach here
|
||||
_raise_for_error(last_resp or resp)
|
||||
return {} # unreachable; satisfies return type
|
||||
|
||||
def _run(self, q: str | None = None, **params: Any) -> Any:
|
||||
# Allow positional usage: tool.run("latest Brave browser features")
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai_tools.tools.brave_search_tool.base import BraveSearchToolBase
|
||||
from crewai_tools.tools.brave_search_tool.response_types import LLMContext
|
||||
from crewai_tools.tools.brave_search_tool.schemas import (
|
||||
LLMContextHeaders,
|
||||
LLMContextParams,
|
||||
@@ -27,6 +26,6 @@ class BraveLLMContextTool(BraveSearchToolBase):
|
||||
def _refine_request_payload(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return params
|
||||
|
||||
def _refine_response(self, response: LLMContext.Response) -> LLMContext.Response:
|
||||
def _refine_response(self, response: dict[str, Any]) -> Any:
|
||||
"""The LLM Context response schema is fairly simple. Return as is."""
|
||||
return response
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -65,8 +65,8 @@ class BraveLocalPOIsTool(BraveSearchToolBase):
|
||||
def _refine_request_payload(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return params
|
||||
|
||||
def _refine_response(self, response: LocalPOIsResponse) -> list[dict[str, Any]]:
|
||||
results = response.get("results", [])
|
||||
def _refine_response(self, response: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = response.get("results", [])
|
||||
return [
|
||||
{
|
||||
"title": result.get("title"),
|
||||
@@ -76,7 +76,7 @@ class BraveLocalPOIsTool(BraveSearchToolBase):
|
||||
"contact": result.get("contact", {}).get("telephone")
|
||||
or result.get("contact", {}).get("email")
|
||||
or None,
|
||||
"opening_hours": _simplify_opening_hours(result),
|
||||
"opening_hours": _simplify_opening_hours(cast(LocationResult, result)),
|
||||
}
|
||||
for result in results
|
||||
]
|
||||
@@ -97,9 +97,8 @@ class BraveLocalPOIsDescriptionTool(BraveSearchToolBase):
|
||||
def _refine_request_payload(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return params
|
||||
|
||||
def _refine_response(self, response: LocalPOIsResponse) -> list[dict[str, Any]]:
|
||||
# Make the response more concise, and easier to consume
|
||||
results = response.get("results", [])
|
||||
def _refine_response(self, response: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = response.get("results", [])
|
||||
return [
|
||||
{
|
||||
"id": result.get("id"),
|
||||
|
||||
@@ -50,7 +50,7 @@ class BraveSearchTool(BaseTool):
|
||||
_last_request_time: ClassVar[float] = 0
|
||||
_min_request_interval: ClassVar[float] = 1.0 # seconds
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if "BRAVE_API_KEY" not in os.environ:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
@@ -13,7 +15,7 @@ class BrightDataConfig(BaseModel):
|
||||
DEFAULT_POLLING_INTERVAL: int = 1
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
def from_env(cls) -> BrightDataConfig:
|
||||
return cls(
|
||||
API_URL=os.environ.get("BRIGHTDATA_API_URL", "https://api.brightdata.com"),
|
||||
DEFAULT_TIMEOUT=int(os.environ.get("BRIGHTDATA_DEFAULT_TIMEOUT", "600")),
|
||||
@@ -26,12 +28,12 @@ class BrightDataConfig(BaseModel):
|
||||
class BrightDataDatasetToolException(Exception): # noqa: N818
|
||||
"""Exception raised for custom error in the application."""
|
||||
|
||||
def __init__(self, message, error_code):
|
||||
def __init__(self, message: str, error_code: int) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return f"{self.message} (Error Code: {self.error_code})"
|
||||
|
||||
|
||||
@@ -62,7 +64,7 @@ config = BrightDataConfig.from_env()
|
||||
BRIGHTDATA_API_URL = config.API_URL
|
||||
timeout = config.DEFAULT_TIMEOUT
|
||||
|
||||
datasets = [
|
||||
datasets: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "amazon_product",
|
||||
"dataset_id": "gd_l7q7dkf244hwjntr0",
|
||||
@@ -440,7 +442,7 @@ class BrightDataDatasetTool(BaseTool):
|
||||
self.zipcode = zipcode
|
||||
self.additional_params = additional_params
|
||||
|
||||
def filter_dataset_by_id(self, target_id):
|
||||
def filter_dataset_by_id(self, target_id: str) -> list[dict[str, Any]]:
|
||||
return [dataset for dataset in datasets if dataset["id"] == target_id]
|
||||
|
||||
async def get_dataset_data_async(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
@@ -11,7 +13,7 @@ class BrightDataConfig(BaseModel):
|
||||
API_URL: str = "https://api.brightdata.com/request"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
def from_env(cls) -> BrightDataConfig:
|
||||
return cls(
|
||||
API_URL=os.environ.get(
|
||||
"BRIGHTDATA_API_URL", "https://api.brightdata.com/request"
|
||||
@@ -127,7 +129,7 @@ class BrightDataSearchTool(BaseTool):
|
||||
if not self.zone:
|
||||
raise ValueError("BRIGHT_DATA_ZONE environment variable is required.")
|
||||
|
||||
def get_search_url(self, engine: str, query: str):
|
||||
def get_search_url(self, engine: str, query: str) -> str:
|
||||
if engine == "yandex":
|
||||
return f"https://yandex.com/search/?text=${query}"
|
||||
if engine == "bing":
|
||||
@@ -143,7 +145,7 @@ class BrightDataSearchTool(BaseTool):
|
||||
search_type: str | None = None,
|
||||
device_type: str | None = None,
|
||||
parse_results: bool | None = None,
|
||||
**kwargs,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Executes a search query using Bright Data SERP API and returns results.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@@ -5,12 +7,14 @@ from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, Field
|
||||
import requests
|
||||
|
||||
from crewai_tools.security.safe_path import validate_url
|
||||
|
||||
|
||||
class BrightDataConfig(BaseModel):
|
||||
API_URL: str = "https://api.brightdata.com/request"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
def from_env(cls) -> BrightDataConfig:
|
||||
return cls(
|
||||
API_URL=os.environ.get(
|
||||
"BRIGHTDATA_API_URL", "https://api.brightdata.com/request"
|
||||
@@ -132,6 +136,7 @@ class BrightDataWebUnlockerTool(BaseTool):
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
validate_url(url)
|
||||
try:
|
||||
response = requests.post(
|
||||
self.base_url, json=payload, headers=headers, timeout=30
|
||||
|
||||
@@ -42,15 +42,15 @@ class BrowserbaseLoadTool(BaseTool):
|
||||
text_content: bool | None = False,
|
||||
session_id: str | None = None,
|
||||
proxy: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if not self.api_key:
|
||||
raise EnvironmentError(
|
||||
"BROWSERBASE_API_KEY environment variable is required for initialization"
|
||||
)
|
||||
try:
|
||||
from browserbase import Browserbase # type: ignore
|
||||
from browserbase import Browserbase
|
||||
except ImportError:
|
||||
import click
|
||||
|
||||
@@ -60,7 +60,7 @@ class BrowserbaseLoadTool(BaseTool):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["uv", "add", "browserbase"], check=True) # noqa: S607
|
||||
from browserbase import Browserbase # type: ignore
|
||||
from browserbase import Browserbase
|
||||
else:
|
||||
raise ImportError(
|
||||
"`browserbase` package not found, please run `uv add browserbase`"
|
||||
@@ -71,7 +71,7 @@ class BrowserbaseLoadTool(BaseTool):
|
||||
self.session_id = session_id
|
||||
self.proxy = proxy
|
||||
|
||||
def _run(self, url: str):
|
||||
def _run(self, url: str) -> Any:
|
||||
return self.browserbase.load_url( # type: ignore[union-attr]
|
||||
url, self.text_content, self.session_id, self.proxy
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
@@ -26,7 +28,7 @@ class CodeDocsSearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = CodeDocsSearchToolSchema
|
||||
|
||||
def __init__(self, docs_url: str | None = None, **kwargs):
|
||||
def __init__(self, docs_url: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if docs_url is not None:
|
||||
self.add(docs_url)
|
||||
@@ -34,7 +36,7 @@ class CodeDocsSearchTool(RagTool):
|
||||
self.args_schema = FixedCodeDocsSearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(self, docs_url: str) -> None:
|
||||
def add(self, docs_url: str) -> None: # type: ignore[override]
|
||||
super().add(docs_url, data_type=DataType.DOCS_SITE)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
RUN pip install requests beautifulsoup4
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspace
|
||||
@@ -1,95 +0,0 @@
|
||||
# CodeInterpreterTool
|
||||
|
||||
## Description
|
||||
This tool is used to give the Agent the ability to run code (Python3) from the code generated by the Agent itself. The code is executed in a Docker container for secure isolation.
|
||||
|
||||
It is incredibly useful since it allows the Agent to generate code, run it in an isolated environment, get the result and use it to make decisions.
|
||||
|
||||
## ⚠️ Security Requirements
|
||||
|
||||
**Docker is REQUIRED** for safe code execution. The tool will refuse to execute code without Docker to prevent security vulnerabilities.
|
||||
|
||||
### Why Docker is Required
|
||||
|
||||
Previous versions included a "restricted sandbox" fallback when Docker was unavailable. This has been **removed** due to critical security vulnerabilities:
|
||||
|
||||
- The Python-based sandbox could be escaped via object introspection
|
||||
- Attackers could recover the original `__import__` function and access any module
|
||||
- This allowed arbitrary command execution on the host system
|
||||
|
||||
**Docker provides real process isolation** and is the only secure way to execute untrusted code.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker (REQUIRED)** - Install from [docker.com](https://docs.docker.com/get-docker/)
|
||||
|
||||
## Installation
|
||||
Install the crewai_tools package
|
||||
```shell
|
||||
pip install 'crewai[tools]'
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Remember that when using this tool, the code must be generated by the Agent itself. The code must be Python3 code. It will take some time the first time to run because it needs to build the Docker image.
|
||||
|
||||
### Basic Usage (Docker Container - Recommended)
|
||||
|
||||
```python
|
||||
from crewai_tools import CodeInterpreterTool
|
||||
|
||||
Agent(
|
||||
...
|
||||
tools=[CodeInterpreterTool()],
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Dockerfile
|
||||
|
||||
If you need to pass your own Dockerfile:
|
||||
|
||||
```python
|
||||
from crewai_tools import CodeInterpreterTool
|
||||
|
||||
Agent(
|
||||
...
|
||||
tools=[CodeInterpreterTool(user_dockerfile_path="<Dockerfile_path>")],
|
||||
)
|
||||
```
|
||||
|
||||
### Manual Docker Host Configuration
|
||||
|
||||
If it is difficult to connect to the Docker daemon automatically (especially for macOS users), you can set up the Docker host manually:
|
||||
|
||||
```python
|
||||
from crewai_tools import CodeInterpreterTool
|
||||
|
||||
Agent(
|
||||
...
|
||||
tools=[CodeInterpreterTool(
|
||||
user_docker_base_url="<Docker Host Base Url>",
|
||||
user_dockerfile_path="<Dockerfile_path>"
|
||||
)],
|
||||
)
|
||||
```
|
||||
|
||||
### Unsafe Mode (NOT RECOMMENDED)
|
||||
|
||||
If you absolutely cannot use Docker and **fully trust the code source**, you can use unsafe mode:
|
||||
|
||||
```python
|
||||
from crewai_tools import CodeInterpreterTool
|
||||
|
||||
# WARNING: Only use with fully trusted code!
|
||||
Agent(
|
||||
...
|
||||
tools=[CodeInterpreterTool(unsafe_mode=True)],
|
||||
)
|
||||
```
|
||||
|
||||
**⚠️ SECURITY WARNING:** `unsafe_mode=True` executes code directly on the host without any isolation. Only use this if:
|
||||
- You completely trust the code being executed
|
||||
- You understand the security risks
|
||||
- You cannot install Docker in your environment
|
||||
|
||||
For production use, **always use Docker** (the default mode).
|
||||
@@ -1,423 +0,0 @@
|
||||
"""Code Interpreter Tool for executing Python code in isolated environments.
|
||||
|
||||
This module provides a tool for executing Python code either in a Docker container for
|
||||
safe isolation or directly in a restricted sandbox. It includes mechanisms for blocking
|
||||
potentially unsafe operations and importing restricted modules.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import Any, ClassVar, TypedDict
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from docker import ( # type: ignore[import-untyped]
|
||||
DockerClient,
|
||||
from_env as docker_from_env,
|
||||
)
|
||||
from docker.errors import ImageNotFound, NotFound # type: ignore[import-untyped]
|
||||
from docker.models.containers import Container # type: ignore[import-untyped]
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from crewai_tools.printer import Printer
|
||||
|
||||
|
||||
class RunKwargs(TypedDict, total=False):
|
||||
"""Keyword arguments for the _run method."""
|
||||
|
||||
code: str
|
||||
libraries_used: list[str]
|
||||
|
||||
|
||||
class CodeInterpreterSchema(BaseModel):
|
||||
"""Schema for defining inputs to the CodeInterpreterTool.
|
||||
|
||||
This schema defines the required parameters for code execution,
|
||||
including the code to run and any libraries that need to be installed.
|
||||
"""
|
||||
|
||||
code: str = Field(
|
||||
...,
|
||||
description="Python3 code used to be interpreted in the Docker container. ALWAYS PRINT the final result and the output of the code",
|
||||
)
|
||||
|
||||
libraries_used: list[str] = Field(
|
||||
...,
|
||||
description="List of libraries used in the code with proper installing names separated by commas. Example: numpy,pandas,beautifulsoup4",
|
||||
)
|
||||
|
||||
|
||||
class SandboxPython:
|
||||
"""INSECURE: A restricted Python execution environment with known vulnerabilities.
|
||||
|
||||
WARNING: This class does NOT provide real security isolation and is vulnerable to
|
||||
sandbox escape attacks via Python object introspection. Attackers can recover the
|
||||
original __import__ function and bypass all restrictions.
|
||||
|
||||
DO NOT USE for untrusted code execution. Use Docker containers instead.
|
||||
|
||||
This class attempts to restrict access to dangerous modules and built-in functions
|
||||
but provides no real security boundary against a motivated attacker.
|
||||
"""
|
||||
|
||||
BLOCKED_MODULES: ClassVar[set[str]] = {
|
||||
"os",
|
||||
"sys",
|
||||
"subprocess",
|
||||
"shutil",
|
||||
"importlib",
|
||||
"inspect",
|
||||
"tempfile",
|
||||
"sysconfig",
|
||||
"builtins",
|
||||
}
|
||||
|
||||
UNSAFE_BUILTINS: ClassVar[set[str]] = {
|
||||
"exec",
|
||||
"eval",
|
||||
"open",
|
||||
"compile",
|
||||
"input",
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
"help",
|
||||
"dir",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def restricted_import(
|
||||
name: str,
|
||||
custom_globals: dict[str, Any] | None = None,
|
||||
custom_locals: dict[str, Any] | None = None,
|
||||
fromlist: list[str] | None = None,
|
||||
level: int = 0,
|
||||
) -> ModuleType:
|
||||
"""A restricted import function that blocks importing of unsafe modules.
|
||||
|
||||
Args:
|
||||
name: The name of the module to import.
|
||||
custom_globals: Global namespace to use.
|
||||
custom_locals: Local namespace to use.
|
||||
fromlist: List of items to import from the module.
|
||||
level: The level value passed to __import__.
|
||||
|
||||
Returns:
|
||||
The imported module if allowed.
|
||||
|
||||
Raises:
|
||||
ImportError: If the module is in the blocked modules list.
|
||||
"""
|
||||
if name in SandboxPython.BLOCKED_MODULES:
|
||||
raise ImportError(f"Importing '{name}' is not allowed.")
|
||||
return __import__(name, custom_globals, custom_locals, fromlist or (), level)
|
||||
|
||||
@staticmethod
|
||||
def safe_builtins() -> dict[str, Any]:
|
||||
"""Creates a dictionary of built-in functions with unsafe ones removed.
|
||||
|
||||
Returns:
|
||||
A dictionary of safe built-in functions and objects.
|
||||
"""
|
||||
import builtins
|
||||
|
||||
safe_builtins = {
|
||||
k: v
|
||||
for k, v in builtins.__dict__.items()
|
||||
if k not in SandboxPython.UNSAFE_BUILTINS
|
||||
}
|
||||
safe_builtins["__import__"] = SandboxPython.restricted_import
|
||||
return safe_builtins
|
||||
|
||||
@staticmethod
|
||||
def exec(code: str, locals_: dict[str, Any]) -> None:
|
||||
"""Executes Python code in a restricted environment.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute as a string.
|
||||
locals_: A dictionary that will be used for local variable storage.
|
||||
"""
|
||||
exec(code, {"__builtins__": SandboxPython.safe_builtins()}, locals_) # noqa: S102
|
||||
|
||||
|
||||
class CodeInterpreterTool(BaseTool):
|
||||
"""A tool for executing Python code in isolated environments.
|
||||
|
||||
This tool provides functionality to run Python code either in a Docker container
|
||||
for safe isolation or directly in a restricted sandbox. It can handle installing
|
||||
Python packages and executing arbitrary Python code.
|
||||
"""
|
||||
|
||||
name: str = "Code Interpreter"
|
||||
description: str = "Interprets Python3 code strings with a final print statement."
|
||||
args_schema: type[BaseModel] = CodeInterpreterSchema
|
||||
default_image_tag: str = "code-interpreter:latest"
|
||||
code: str | None = None
|
||||
user_dockerfile_path: str | None = None
|
||||
user_docker_base_url: str | None = None
|
||||
unsafe_mode: bool = False
|
||||
|
||||
@staticmethod
|
||||
def _get_installed_package_path() -> str:
|
||||
"""Gets the installation path of the crewai_tools package.
|
||||
|
||||
Returns:
|
||||
The directory path where the package is installed.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the package cannot be found.
|
||||
"""
|
||||
spec = importlib.util.find_spec("crewai_tools")
|
||||
if spec is None or spec.origin is None:
|
||||
raise RuntimeError("Cannot find crewai_tools package installation path")
|
||||
return os.path.dirname(spec.origin)
|
||||
|
||||
def _verify_docker_image(self) -> None:
|
||||
"""Verifies if the Docker image is available or builds it if necessary.
|
||||
|
||||
Checks if the required Docker image exists. If not, builds it using either a
|
||||
user-provided Dockerfile or the default one included with the package.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the Dockerfile cannot be found.
|
||||
"""
|
||||
client = (
|
||||
docker_from_env()
|
||||
if self.user_docker_base_url is None
|
||||
else DockerClient(base_url=self.user_docker_base_url)
|
||||
)
|
||||
|
||||
try:
|
||||
client.images.get(self.default_image_tag)
|
||||
|
||||
except ImageNotFound:
|
||||
if self.user_dockerfile_path and os.path.exists(self.user_dockerfile_path):
|
||||
dockerfile_path = self.user_dockerfile_path
|
||||
else:
|
||||
package_path = self._get_installed_package_path()
|
||||
dockerfile_path = os.path.join(
|
||||
package_path, "tools/code_interpreter_tool"
|
||||
)
|
||||
if not os.path.exists(dockerfile_path):
|
||||
raise FileNotFoundError(
|
||||
f"Dockerfile not found in {dockerfile_path}"
|
||||
) from None
|
||||
|
||||
client.images.build(
|
||||
path=dockerfile_path,
|
||||
tag=self.default_image_tag,
|
||||
rm=True,
|
||||
)
|
||||
|
||||
def _run(self, **kwargs: Unpack[RunKwargs]) -> str:
|
||||
"""Runs the code interpreter tool with the provided arguments.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments that should include 'code' and 'libraries_used'.
|
||||
|
||||
Returns:
|
||||
The output of the executed code as a string.
|
||||
"""
|
||||
code: str | None = kwargs.get("code", self.code)
|
||||
libraries_used: list[str] = kwargs.get("libraries_used", [])
|
||||
|
||||
if not code:
|
||||
return "No code provided to execute."
|
||||
|
||||
if self.unsafe_mode:
|
||||
return self.run_code_unsafe(code, libraries_used)
|
||||
return self.run_code_safety(code, libraries_used)
|
||||
|
||||
@staticmethod
|
||||
def _install_libraries(container: Container, libraries: list[str]) -> None:
|
||||
"""Installs required Python libraries in the Docker container.
|
||||
|
||||
Args:
|
||||
container: The Docker container where libraries will be installed.
|
||||
libraries: A list of library names to install using pip.
|
||||
"""
|
||||
for library in libraries:
|
||||
container.exec_run(["pip", "install", library])
|
||||
|
||||
def _init_docker_container(self) -> Container:
|
||||
"""Initializes and returns a Docker container for code execution.
|
||||
|
||||
Stops and removes any existing container with the same name before creating
|
||||
a new one. Maps the current working directory to /workspace in the container.
|
||||
|
||||
Returns:
|
||||
A Docker container object ready for code execution.
|
||||
"""
|
||||
container_name = "code-interpreter"
|
||||
client = docker_from_env()
|
||||
current_path = os.getcwd()
|
||||
|
||||
# Check if the container is already running
|
||||
try:
|
||||
existing_container = client.containers.get(container_name)
|
||||
existing_container.stop()
|
||||
existing_container.remove()
|
||||
except NotFound:
|
||||
pass # Container does not exist, no need to remove
|
||||
|
||||
return client.containers.run(
|
||||
self.default_image_tag,
|
||||
detach=True,
|
||||
tty=True,
|
||||
working_dir="/workspace",
|
||||
name=container_name,
|
||||
volumes={current_path: {"bind": "/workspace", "mode": "rw"}}, # type: ignore
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_docker_available() -> bool:
|
||||
"""Checks if Docker is available and running on the system.
|
||||
|
||||
Attempts to run the 'docker info' command to verify Docker availability.
|
||||
Prints appropriate messages if Docker is not installed or not running.
|
||||
|
||||
Returns:
|
||||
True if Docker is available and running, False otherwise.
|
||||
"""
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "info"], # noqa: S607
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=1,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
Printer.print(
|
||||
"Docker is installed but not running or inaccessible.",
|
||||
color="bold_purple",
|
||||
)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
Printer.print("Docker is not installed", color="bold_purple")
|
||||
return False
|
||||
|
||||
def run_code_safety(self, code: str, libraries_used: list[str]) -> str:
|
||||
"""Runs code in the safest available environment.
|
||||
|
||||
Requires Docker to be available for secure code execution. Fails closed
|
||||
if Docker is not available to prevent sandbox escape vulnerabilities.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute as a string.
|
||||
libraries_used: A list of Python library names to install before execution.
|
||||
|
||||
Returns:
|
||||
The output of the executed code as a string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If Docker is not available, as the restricted sandbox
|
||||
is vulnerable to escape attacks and should not be used
|
||||
for untrusted code execution.
|
||||
"""
|
||||
if self._check_docker_available():
|
||||
return self.run_code_in_docker(code, libraries_used)
|
||||
|
||||
error_msg = (
|
||||
"Docker is required for safe code execution but is not available. "
|
||||
"The restricted sandbox fallback has been removed due to security vulnerabilities "
|
||||
"that allow sandbox escape via Python object introspection. "
|
||||
"Please install Docker (https://docs.docker.com/get-docker/) or use unsafe_mode=True "
|
||||
"if you trust the code source and understand the security risks."
|
||||
)
|
||||
Printer.print(error_msg, color="bold_red")
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
def run_code_in_docker(self, code: str, libraries_used: list[str]) -> str:
|
||||
"""Runs Python code in a Docker container for safe isolation.
|
||||
|
||||
Creates a Docker container, installs the required libraries, executes the code,
|
||||
and then cleans up by stopping and removing the container.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute as a string.
|
||||
libraries_used: A list of Python library names to install before execution.
|
||||
|
||||
Returns:
|
||||
The output of the executed code as a string, or an error message if execution failed.
|
||||
"""
|
||||
Printer.print("Running code in Docker environment", color="bold_blue")
|
||||
self._verify_docker_image()
|
||||
container = self._init_docker_container()
|
||||
self._install_libraries(container, libraries_used)
|
||||
|
||||
exec_result = container.exec_run(["python3", "-c", code])
|
||||
|
||||
container.stop()
|
||||
container.remove()
|
||||
|
||||
if exec_result.exit_code != 0:
|
||||
return f"Something went wrong while running the code: \n{exec_result.output.decode('utf-8')}"
|
||||
return exec_result.output.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def run_code_in_restricted_sandbox(code: str) -> str:
|
||||
"""DEPRECATED AND INSECURE: Runs Python code in a restricted sandbox environment.
|
||||
|
||||
WARNING: This method is vulnerable to sandbox escape attacks via Python object
|
||||
introspection and should NOT be used for untrusted code execution. It has been
|
||||
deprecated and is only kept for backward compatibility with trusted code.
|
||||
|
||||
The "restricted" environment can be bypassed by attackers who can:
|
||||
- Use object graph introspection to recover the original __import__ function
|
||||
- Access any Python module including os, subprocess, sys, etc.
|
||||
- Execute arbitrary commands on the host system
|
||||
|
||||
Use run_code_in_docker() for secure code execution, or run_code_unsafe()
|
||||
if you explicitly acknowledge the security risks.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute as a string.
|
||||
|
||||
Returns:
|
||||
The value of the 'result' variable from the executed code,
|
||||
or an error message if execution failed.
|
||||
"""
|
||||
Printer.print(
|
||||
"WARNING: Running code in INSECURE restricted sandbox (vulnerable to escape attacks)",
|
||||
color="bold_red"
|
||||
)
|
||||
exec_locals: dict[str, Any] = {}
|
||||
try:
|
||||
SandboxPython.exec(code=code, locals_=exec_locals)
|
||||
return exec_locals.get("result", "No result variable found.")
|
||||
except Exception as e:
|
||||
return f"An error occurred: {e!s}"
|
||||
|
||||
@staticmethod
|
||||
def run_code_unsafe(code: str, libraries_used: list[str]) -> str:
|
||||
"""Runs code directly on the host machine without any safety restrictions.
|
||||
|
||||
WARNING: This mode is unsafe and should only be used in trusted environments
|
||||
with code from trusted sources.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute as a string.
|
||||
libraries_used: A list of Python library names to install before execution.
|
||||
|
||||
Returns:
|
||||
The value of the 'result' variable from the executed code,
|
||||
or an error message if execution failed.
|
||||
"""
|
||||
Printer.print("WARNING: Running code in unsafe mode", color="bold_magenta")
|
||||
# Install libraries on the host machine
|
||||
for library in libraries_used:
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", library], check=False) # noqa: S603
|
||||
|
||||
# Execute the code
|
||||
try:
|
||||
exec_locals: dict[str, Any] = {}
|
||||
exec(code, {}, exec_locals) # noqa: S102
|
||||
return exec_locals.get("result", "No result variable found.")
|
||||
except Exception as e:
|
||||
return f"An error occurred: {e!s}"
|
||||
@@ -10,7 +10,7 @@ import typing_extensions as te
|
||||
class ComposioTool(BaseTool):
|
||||
"""Wrapper for composio tools."""
|
||||
|
||||
composio_action: t.Callable
|
||||
composio_action: t.Callable[..., t.Any]
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
EnvVar(
|
||||
@@ -70,7 +70,7 @@ class ComposioTool(BaseTool):
|
||||
schema = action_schema.model_dump(exclude_none=True)
|
||||
entity_id = kwargs.pop("entity_id", DEFAULT_ENTITY_ID)
|
||||
|
||||
def function(**kwargs: t.Any) -> dict:
|
||||
def function(**kwargs: t.Any) -> dict[str, t.Any]:
|
||||
"""Wrapper function for composio action."""
|
||||
return toolset.execute_action(
|
||||
action=Action(schema["name"]),
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Any
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
|
||||
|
||||
class ContextualAICreateAgentSchema(BaseModel):
|
||||
"""Schema for contextual create agent tool."""
|
||||
@@ -28,7 +30,7 @@ class ContextualAICreateAgentTool(BaseTool):
|
||||
default_factory=lambda: ["contextual-client"]
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
try:
|
||||
from contextual import ContextualAI
|
||||
@@ -47,6 +49,7 @@ class ContextualAICreateAgentTool(BaseTool):
|
||||
document_paths: list[str],
|
||||
) -> str:
|
||||
"""Create a complete RAG pipeline with documents."""
|
||||
resolved_paths = [validate_file_path(doc_path) for doc_path in document_paths]
|
||||
try:
|
||||
import os
|
||||
|
||||
@@ -56,7 +59,7 @@ class ContextualAICreateAgentTool(BaseTool):
|
||||
|
||||
# Upload documents
|
||||
document_ids = []
|
||||
for doc_path in document_paths:
|
||||
for doc_path in resolved_paths:
|
||||
if not os.path.exists(doc_path):
|
||||
raise FileNotFoundError(f"Document not found: {doc_path}")
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
|
||||
|
||||
class ContextualAIParseSchema(BaseModel):
|
||||
"""Schema for contextual parse tool."""
|
||||
@@ -45,6 +47,7 @@ class ContextualAIParseTool(BaseTool):
|
||||
"""Parse a document using Contextual AI's parser."""
|
||||
if output_types is None:
|
||||
output_types = ["markdown-per-page"]
|
||||
file_path = validate_file_path(file_path)
|
||||
try:
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -31,7 +31,7 @@ class ContextualAIQueryTool(BaseTool):
|
||||
default_factory=lambda: ["contextual-client"]
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
try:
|
||||
from contextual import ContextualAI
|
||||
@@ -99,20 +99,19 @@ class ContextualAIQueryTool(BaseTool):
|
||||
response = self.contextual_client.agents.query.create(
|
||||
agent_id=agent_id, messages=[{"role": "user", "content": query}]
|
||||
)
|
||||
if hasattr(response, "content"):
|
||||
return response.content
|
||||
if hasattr(response, "message"):
|
||||
content = getattr(response, "content", None)
|
||||
if content is not None:
|
||||
return str(content)
|
||||
message = getattr(response, "message", None)
|
||||
if message is not None:
|
||||
msg_content = getattr(message, "content", None)
|
||||
return str(msg_content) if msg_content is not None else str(message)
|
||||
messages = getattr(response, "messages", None)
|
||||
if messages and len(messages) > 0:
|
||||
last_message = messages[-1]
|
||||
last_content = getattr(last_message, "content", None)
|
||||
return (
|
||||
response.message.content
|
||||
if hasattr(response.message, "content")
|
||||
else str(response.message)
|
||||
)
|
||||
if hasattr(response, "messages") and len(response.messages) > 0:
|
||||
last_message = response.messages[-1]
|
||||
return (
|
||||
last_message.content
|
||||
if hasattr(last_message, "content")
|
||||
else str(last_message)
|
||||
str(last_content) if last_content is not None else str(last_message)
|
||||
)
|
||||
return str(response)
|
||||
except Exception as e:
|
||||
|
||||
@@ -15,11 +15,11 @@ try:
|
||||
COUCHBASE_AVAILABLE = True
|
||||
except ImportError:
|
||||
COUCHBASE_AVAILABLE = False
|
||||
search = Any
|
||||
Cluster = Any
|
||||
SearchOptions = Any
|
||||
VectorQuery = Any
|
||||
VectorSearch = Any
|
||||
search = Any # type: ignore[assignment,unused-ignore]
|
||||
Cluster = Any # type: ignore[assignment,unused-ignore]
|
||||
SearchOptions = Any # type: ignore[assignment,unused-ignore]
|
||||
VectorQuery = Any # type: ignore[assignment,unused-ignore]
|
||||
VectorSearch = Any # type: ignore[assignment,unused-ignore]
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
||||
@@ -41,7 +41,7 @@ class CouchbaseFTSVectorSearchTool(BaseTool):
|
||||
name: str = "CouchbaseFTSVectorSearchTool"
|
||||
description: str = "A tool to search the Couchbase database for relevant information on internal documents."
|
||||
args_schema: type[BaseModel] = CouchbaseToolSchema
|
||||
cluster: SkipValidation[Cluster] = Field(
|
||||
cluster: SkipValidation[Any] = Field(
|
||||
description="An instance of the Couchbase Cluster connected to the desired Couchbase server.",
|
||||
)
|
||||
collection_name: str = Field(
|
||||
@@ -136,7 +136,7 @@ class CouchbaseFTSVectorSearchTool(BaseTool):
|
||||
|
||||
return True
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the CouchbaseFTSVectorSearchTool.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
@@ -26,7 +28,7 @@ class CSVSearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = CSVSearchToolSchema
|
||||
|
||||
def __init__(self, csv: str | None = None, **kwargs):
|
||||
def __init__(self, csv: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if csv is not None:
|
||||
self.add(csv)
|
||||
@@ -34,7 +36,7 @@ class CSVSearchTool(RagTool):
|
||||
self.args_schema = FixedCSVSearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(self, csv: str) -> None:
|
||||
def add(self, csv: str) -> None: # type: ignore[override]
|
||||
super().add(csv, data_type=DataType.CSV)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from openai import Omit, OpenAI
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ class DallETool(BaseTool):
|
||||
]
|
||||
| None
|
||||
) = "1024x1024"
|
||||
quality: (
|
||||
Literal["standard", "hd", "low", "medium", "high", "auto"] | None | Omit
|
||||
) = "standard"
|
||||
quality: Literal["standard", "hd", "low", "medium", "high", "auto"] | None = (
|
||||
"standard"
|
||||
)
|
||||
n: int = 1
|
||||
|
||||
env_vars: list[EnvVar] = Field(
|
||||
@@ -48,7 +48,7 @@ class DallETool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
client = OpenAI()
|
||||
|
||||
image_description = kwargs.get("image_description")
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Any
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_directory_path
|
||||
|
||||
|
||||
class FixedDirectoryReadToolSchema(BaseModel):
|
||||
"""Input for DirectoryReadTool."""
|
||||
@@ -23,7 +25,7 @@ class DirectoryReadTool(BaseTool):
|
||||
args_schema: type[BaseModel] = DirectoryReadToolSchema
|
||||
directory: str | None = None
|
||||
|
||||
def __init__(self, directory: str | None = None, **kwargs):
|
||||
def __init__(self, directory: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if directory is not None:
|
||||
self.directory = directory
|
||||
@@ -39,6 +41,7 @@ class DirectoryReadTool(BaseTool):
|
||||
if directory is None:
|
||||
raise ValueError("Directory must be provided.")
|
||||
|
||||
directory = validate_directory_path(directory)
|
||||
if directory[-1] == "/":
|
||||
directory = directory[:-1]
|
||||
files_list = [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
from crewai_tools.security.safe_path import validate_directory_path
|
||||
from crewai_tools.tools.rag.rag_tool import RagTool
|
||||
|
||||
|
||||
@@ -26,7 +29,7 @@ class DirectorySearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = DirectorySearchToolSchema
|
||||
|
||||
def __init__(self, directory: str | None = None, **kwargs):
|
||||
def __init__(self, directory: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if directory is not None:
|
||||
self.add(directory)
|
||||
@@ -34,7 +37,8 @@ class DirectorySearchTool(RagTool):
|
||||
self.args_schema = FixedDirectorySearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(self, directory: str) -> None:
|
||||
def add(self, directory: str) -> None: # type: ignore[override]
|
||||
directory = validate_directory_path(directory)
|
||||
super().add(directory, data_type=DataType.DIRECTORY)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -34,7 +34,7 @@ class DOCXSearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = DOCXSearchToolSchema
|
||||
|
||||
def __init__(self, docx: str | None = None, **kwargs):
|
||||
def __init__(self, docx: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if docx is not None:
|
||||
self.add(docx)
|
||||
@@ -42,7 +42,7 @@ class DOCXSearchTool(RagTool):
|
||||
self.args_schema = FixedDOCXSearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(self, docx: str) -> None:
|
||||
def add(self, docx: str) -> None: # type: ignore[override]
|
||||
super().add(docx, data_type=DataType.DOCX)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -71,8 +71,8 @@ class EXASearchTool(BaseTool):
|
||||
content: bool | None = False,
|
||||
summary: bool | None = False,
|
||||
type: str | None = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Any
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
|
||||
|
||||
class FileReadToolSchema(BaseModel):
|
||||
"""Input for FileReadTool."""
|
||||
@@ -76,6 +78,7 @@ class FileReadTool(BaseTool):
|
||||
if file_path is None:
|
||||
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
|
||||
|
||||
file_path = validate_file_path(file_path)
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
if start_line == 1 and line_count is None:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def strtobool(val) -> bool:
|
||||
def strtobool(val: str | bool) -> bool:
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
val = val.lower()
|
||||
@@ -30,28 +31,41 @@ class FileWriterTool(BaseTool):
|
||||
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
try:
|
||||
directory = kwargs.get("directory") or "./"
|
||||
filename = kwargs["filename"]
|
||||
|
||||
filepath = os.path.join(directory, filename)
|
||||
|
||||
# Prevent path traversal: the resolved path must be strictly inside
|
||||
# the resolved directory. This blocks ../sequences, absolute paths in
|
||||
# filename, and symlink escapes regardless of how directory is set.
|
||||
# is_relative_to() does a proper path-component comparison that is
|
||||
# safe on case-insensitive filesystems and avoids the "// " edge case
|
||||
# that plagues startswith(real_directory + os.sep).
|
||||
# We also reject the case where filepath resolves to the directory
|
||||
# itself, since that is not a valid file target.
|
||||
real_directory = Path(directory).resolve()
|
||||
real_filepath = Path(filepath).resolve()
|
||||
if (
|
||||
not real_filepath.is_relative_to(real_directory)
|
||||
or real_filepath == real_directory
|
||||
):
|
||||
return "Error: Invalid file path — the filename must not escape the target directory."
|
||||
|
||||
if kwargs.get("directory"):
|
||||
os.makedirs(kwargs["directory"], exist_ok=True)
|
||||
os.makedirs(real_directory, exist_ok=True)
|
||||
|
||||
# Construct the full path
|
||||
filepath = os.path.join(kwargs.get("directory") or "", kwargs["filename"])
|
||||
|
||||
# Convert overwrite to boolean
|
||||
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
|
||||
|
||||
# Check if file exists and overwrite is not allowed
|
||||
if os.path.exists(filepath) and not kwargs["overwrite"]:
|
||||
return f"File {filepath} already exists and overwrite option was not passed."
|
||||
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
|
||||
return f"File {real_filepath} already exists and overwrite option was not passed."
|
||||
|
||||
# Write content to the file
|
||||
mode = "w" if kwargs["overwrite"] else "x"
|
||||
with open(filepath, mode) as file:
|
||||
with open(real_filepath, mode) as file:
|
||||
file.write(kwargs["content"])
|
||||
return f"Content successfully written to {filepath}"
|
||||
return f"Content successfully written to {real_filepath}"
|
||||
except FileExistsError:
|
||||
return (
|
||||
f"File {filepath} already exists and overwrite option was not passed."
|
||||
)
|
||||
return f"File {real_filepath} already exists and overwrite option was not passed."
|
||||
except KeyError as e:
|
||||
return f"An error occurred while accessing key: {e!s}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,6 +5,8 @@ import zipfile
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
|
||||
|
||||
class FileCompressorToolInput(BaseModel):
|
||||
"""Input schema for FileCompressorTool."""
|
||||
@@ -40,12 +42,15 @@ class FileCompressorTool(BaseTool):
|
||||
overwrite: bool = False,
|
||||
format: str = "zip",
|
||||
) -> str:
|
||||
input_path = validate_file_path(input_path)
|
||||
if not os.path.exists(input_path):
|
||||
return f"Input path '{input_path}' does not exist."
|
||||
|
||||
if not output_path:
|
||||
output_path = self._generate_output_path(input_path, format)
|
||||
|
||||
output_path = validate_file_path(output_path)
|
||||
|
||||
format_extension = {
|
||||
"zip": ".zip",
|
||||
"tar": ".tar",
|
||||
@@ -106,7 +111,7 @@ class FileCompressorTool(BaseTool):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _compress_zip(input_path: str, output_path: str):
|
||||
def _compress_zip(input_path: str, output_path: str) -> None:
|
||||
"""Compresses input into a zip archive."""
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
if os.path.isfile(input_path):
|
||||
@@ -119,7 +124,7 @@ class FileCompressorTool(BaseTool):
|
||||
zipf.write(full_path, arcname)
|
||||
|
||||
@staticmethod
|
||||
def _compress_tar(input_path: str, output_path: str, format: str):
|
||||
def _compress_tar(input_path: str, output_path: str, format: str) -> None:
|
||||
"""Compresses input into a tar archive with the given format."""
|
||||
format_mode = {
|
||||
"tar": "w",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||
|
||||
from crewai_tools.security.safe_path import validate_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
@@ -63,7 +62,7 @@ class FirecrawlCrawlWebsiteTool(BaseTool):
|
||||
},
|
||||
}
|
||||
)
|
||||
_firecrawl: FirecrawlApp | None = PrivateAttr(None)
|
||||
_firecrawl: Any = PrivateAttr(None)
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["firecrawl-py"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
@@ -75,14 +74,14 @@ class FirecrawlCrawlWebsiteTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str | None = None, **kwargs):
|
||||
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key
|
||||
self._initialize_firecrawl()
|
||||
|
||||
def _initialize_firecrawl(self) -> None:
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore
|
||||
from firecrawl import FirecrawlApp
|
||||
|
||||
self._firecrawl = FirecrawlApp(api_key=self.api_key)
|
||||
except ImportError:
|
||||
@@ -105,21 +104,19 @@ class FirecrawlCrawlWebsiteTool(BaseTool):
|
||||
"`firecrawl-py` package not found, please run `uv add firecrawl-py`"
|
||||
) from None
|
||||
|
||||
def _run(self, url: str):
|
||||
def _run(self, url: str) -> Any:
|
||||
if not self._firecrawl:
|
||||
raise RuntimeError("FirecrawlApp not properly initialized")
|
||||
|
||||
url = validate_url(url)
|
||||
return self._firecrawl.crawl(url=url, poll_interval=2, **self.config)
|
||||
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
from firecrawl import FirecrawlApp # noqa: F401
|
||||
|
||||
# Only rebuild if the class hasn't been initialized yet
|
||||
if not hasattr(FirecrawlCrawlWebsiteTool, "_model_rebuilt"):
|
||||
if not getattr(FirecrawlCrawlWebsiteTool, "_model_rebuilt", False):
|
||||
FirecrawlCrawlWebsiteTool.model_rebuild()
|
||||
FirecrawlCrawlWebsiteTool._model_rebuilt = True # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
"""
|
||||
When this tool is not used, then exception can be ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||
|
||||
from crewai_tools.security.safe_path import validate_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
@@ -70,7 +69,7 @@ class FirecrawlScrapeWebsiteTool(BaseTool):
|
||||
}
|
||||
)
|
||||
|
||||
_firecrawl: FirecrawlApp | None = PrivateAttr(None)
|
||||
_firecrawl: Any = PrivateAttr(None)
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["firecrawl-py"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
@@ -82,10 +81,10 @@ class FirecrawlScrapeWebsiteTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str | None = None, **kwargs):
|
||||
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore
|
||||
from firecrawl import FirecrawlApp
|
||||
except ImportError:
|
||||
import click
|
||||
|
||||
@@ -105,21 +104,19 @@ class FirecrawlScrapeWebsiteTool(BaseTool):
|
||||
|
||||
self._firecrawl = FirecrawlApp(api_key=api_key)
|
||||
|
||||
def _run(self, url: str):
|
||||
def _run(self, url: str) -> Any:
|
||||
if not self._firecrawl:
|
||||
raise RuntimeError("FirecrawlApp not properly initialized")
|
||||
|
||||
url = validate_url(url)
|
||||
return self._firecrawl.scrape(url=url, **self.config)
|
||||
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
from firecrawl import FirecrawlApp # noqa: F401
|
||||
|
||||
# Must rebuild model after class is defined
|
||||
if not hasattr(FirecrawlScrapeWebsiteTool, "_model_rebuilt"):
|
||||
if not getattr(FirecrawlScrapeWebsiteTool, "_model_rebuilt", False):
|
||||
FirecrawlScrapeWebsiteTool.model_rebuild()
|
||||
FirecrawlScrapeWebsiteTool._model_rebuilt = True # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
"""
|
||||
When this tool is not used, then exception can be ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
|
||||
|
||||
@@ -65,7 +61,7 @@ class FirecrawlSearchTool(BaseTool):
|
||||
},
|
||||
}
|
||||
)
|
||||
_firecrawl: FirecrawlApp | None = PrivateAttr(None)
|
||||
_firecrawl: Any = PrivateAttr(None)
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["firecrawl-py"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
@@ -77,14 +73,14 @@ class FirecrawlSearchTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str | None = None, **kwargs):
|
||||
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key
|
||||
self._initialize_firecrawl()
|
||||
|
||||
def _initialize_firecrawl(self) -> None:
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore
|
||||
from firecrawl import FirecrawlApp
|
||||
|
||||
self._firecrawl = FirecrawlApp(api_key=self.api_key)
|
||||
except ImportError:
|
||||
@@ -121,13 +117,10 @@ class FirecrawlSearchTool(BaseTool):
|
||||
|
||||
|
||||
try:
|
||||
from firecrawl import FirecrawlApp # type: ignore
|
||||
from firecrawl import FirecrawlApp # noqa: F401
|
||||
|
||||
# Only rebuild if the class hasn't been initialized yet
|
||||
if not hasattr(FirecrawlSearchTool, "_model_rebuilt"):
|
||||
if not getattr(FirecrawlSearchTool, "_model_rebuilt", False):
|
||||
FirecrawlSearchTool.model_rebuild()
|
||||
FirecrawlSearchTool._model_rebuilt = True # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
"""
|
||||
When this tool is not used, then exception can be ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -46,7 +47,7 @@ class GenerateCrewaiAutomationTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
input_data = GenerateCrewaiAutomationToolSchema(**kwargs)
|
||||
response = requests.post( # noqa: S113
|
||||
f"{self.crewai_enterprise_url}/crewai_plus/api/v1/studio",
|
||||
@@ -58,7 +59,7 @@ class GenerateCrewaiAutomationTool(BaseTool):
|
||||
studio_project_url = response.json().get("url")
|
||||
return f"Generated CrewAI Studio project URL: {studio_project_url}"
|
||||
|
||||
def _get_headers(self, organization_id: str | None = None) -> dict:
|
||||
def _get_headers(self, organization_id: str | None = None) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.personal_access_token}",
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
@@ -38,8 +40,8 @@ class GithubSearchTool(RagTool):
|
||||
self,
|
||||
github_repo: str | None = None,
|
||||
content_types: list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
if github_repo and content_types:
|
||||
@@ -48,7 +50,7 @@ class GithubSearchTool(RagTool):
|
||||
self.args_schema = FixedGithubSearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(
|
||||
def add( # type: ignore[override]
|
||||
self,
|
||||
repo: str,
|
||||
content_types: list[str] | None = None,
|
||||
|
||||
@@ -4,13 +4,15 @@ from typing import Any, Literal
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_url
|
||||
|
||||
|
||||
class HyperbrowserLoadToolSchema(BaseModel):
|
||||
url: str = Field(description="Website URL")
|
||||
operation: Literal["scrape", "crawl"] = Field(
|
||||
description="Operation to perform on the website. Either 'scrape' or 'crawl'"
|
||||
)
|
||||
params: dict | None = Field(
|
||||
params: dict[str, Any] | None = Field(
|
||||
description="Optional params for scrape or crawl. For more information on the supported params, visit https://docs.hyperbrowser.ai/reference/sdks/python/scrape#start-scrape-job-and-wait or https://docs.hyperbrowser.ai/reference/sdks/python/crawl#start-crawl-job-and-wait"
|
||||
)
|
||||
|
||||
@@ -42,7 +44,7 @@ class HyperbrowserLoadTool(BaseTool):
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str | None = None, **kwargs):
|
||||
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key or os.getenv("HYPERBROWSER_API_KEY")
|
||||
if not api_key:
|
||||
@@ -65,7 +67,7 @@ class HyperbrowserLoadTool(BaseTool):
|
||||
self.hyperbrowser = Hyperbrowser(api_key=self.api_key)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_params(params: dict) -> dict:
|
||||
def _prepare_params(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare session and scrape options parameters."""
|
||||
try:
|
||||
from hyperbrowser.models.scrape import ( # type: ignore[import-untyped]
|
||||
@@ -91,7 +93,7 @@ class HyperbrowserLoadTool(BaseTool):
|
||||
params["scrape_options"] = ScrapeOptions(**params["scrape_options"])
|
||||
return params
|
||||
|
||||
def _extract_content(self, data: Any | None):
|
||||
def _extract_content(self, data: Any | None) -> str:
|
||||
"""Extract content from response data."""
|
||||
content = ""
|
||||
if data:
|
||||
@@ -102,15 +104,15 @@ class HyperbrowserLoadTool(BaseTool):
|
||||
self,
|
||||
url: str,
|
||||
operation: Literal["scrape", "crawl"] = "scrape",
|
||||
params: dict | None = None,
|
||||
):
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if params is None:
|
||||
params = {}
|
||||
try:
|
||||
from hyperbrowser.models.crawl import ( # type: ignore[import-untyped]
|
||||
StartCrawlJobParams,
|
||||
)
|
||||
from hyperbrowser.models.scrape import ( # type: ignore[import-untyped]
|
||||
from hyperbrowser.models.scrape import (
|
||||
StartScrapeJobParams,
|
||||
)
|
||||
except ImportError as e:
|
||||
@@ -119,6 +121,7 @@ class HyperbrowserLoadTool(BaseTool):
|
||||
) from e
|
||||
|
||||
params = self._prepare_params(params)
|
||||
url = validate_url(url)
|
||||
|
||||
if operation == "scrape":
|
||||
scrape_params = StartScrapeJobParams(url=url, **params)
|
||||
|
||||
@@ -134,7 +134,8 @@ class InvokeCrewAIAutomationTool(BaseTool):
|
||||
json={"inputs": inputs},
|
||||
timeout=30,
|
||||
)
|
||||
return response.json()
|
||||
result: dict[str, Any] = response.json()
|
||||
return result
|
||||
|
||||
def _get_crew_status(self, crew_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a crew task.
|
||||
@@ -153,9 +154,10 @@ class InvokeCrewAIAutomationTool(BaseTool):
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return response.json()
|
||||
result: dict[str, Any] = response.json()
|
||||
return result
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
"""Execute the crew invocation tool."""
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
@@ -172,7 +174,7 @@ class InvokeCrewAIAutomationTool(BaseTool):
|
||||
try:
|
||||
status_response = self._get_crew_status(crew_id=kickoff_id)
|
||||
if status_response.get("state", "").lower() == "success":
|
||||
return status_response.get("result", "No result returned")
|
||||
return str(status_response.get("result", "No result returned"))
|
||||
if status_response.get("state", "").lower() == "failed":
|
||||
return f"Error: Crew task failed. Response: {status_response}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
import requests
|
||||
|
||||
from crewai_tools.security.safe_path import validate_url
|
||||
|
||||
|
||||
class JinaScrapeWebsiteToolInput(BaseModel):
|
||||
"""Input schema for JinaScrapeWebsiteTool."""
|
||||
@@ -15,14 +19,14 @@ class JinaScrapeWebsiteTool(BaseTool):
|
||||
args_schema: type[BaseModel] = JinaScrapeWebsiteToolInput
|
||||
website_url: str | None = None
|
||||
api_key: str | None = None
|
||||
headers: dict = Field(default_factory=dict)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
website_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
custom_headers: dict | None = None,
|
||||
**kwargs,
|
||||
custom_headers: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
if website_url is not None:
|
||||
@@ -43,6 +47,7 @@ class JinaScrapeWebsiteTool(BaseTool):
|
||||
"Website URL must be provided either during initialization or execution"
|
||||
)
|
||||
|
||||
url = validate_url(url)
|
||||
response = requests.get(
|
||||
f"https://r.jina.ai/{url}", headers=self.headers, timeout=15
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.tools.rag.rag_tool import RagTool
|
||||
@@ -27,7 +29,7 @@ class JSONSearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = JSONSearchToolSchema
|
||||
|
||||
def __init__(self, json_path: str | None = None, **kwargs):
|
||||
def __init__(self, json_path: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if json_path is not None:
|
||||
self.add(json_path)
|
||||
|
||||
@@ -20,7 +20,7 @@ class LinkupSearchTool(BaseTool):
|
||||
description: str = (
|
||||
"Performs an API call to Linkup to retrieve contextual information."
|
||||
)
|
||||
_client: LinkupClient = PrivateAttr() # type: ignore
|
||||
_client: Any = PrivateAttr()
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["linkup-sdk"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
default_factory=lambda: [
|
||||
@@ -60,7 +60,7 @@ class LinkupSearchTool(BaseTool):
|
||||
output_type: Literal[
|
||||
"searchResults", "sourcedAnswer", "structured"
|
||||
] = "searchResults",
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
"""Executes a search using the Linkup API.
|
||||
|
||||
:param query: The query to search for.
|
||||
|
||||
@@ -17,11 +17,7 @@ class LlamaIndexTool(BaseTool):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run tool."""
|
||||
from llama_index.core.tools import ( # type: ignore[import-not-found]
|
||||
BaseTool as LlamaBaseTool,
|
||||
)
|
||||
|
||||
tool = cast(LlamaBaseTool, self.llama_index_tool)
|
||||
tool = self.llama_index_tool
|
||||
|
||||
if self.result_as_answer:
|
||||
return tool(*args, **kwargs).content
|
||||
@@ -36,7 +32,6 @@ class LlamaIndexTool(BaseTool):
|
||||
|
||||
if not isinstance(tool, LlamaBaseTool):
|
||||
raise ValueError(f"Expected a LlamaBaseTool, got {type(tool)}")
|
||||
tool = cast(LlamaBaseTool, tool)
|
||||
|
||||
if tool.metadata.fn_schema is None:
|
||||
raise ValueError(
|
||||
@@ -64,9 +59,7 @@ class LlamaIndexTool(BaseTool):
|
||||
from llama_index.core.query_engine import ( # type: ignore[import-not-found]
|
||||
BaseQueryEngine,
|
||||
)
|
||||
from llama_index.core.tools import ( # type: ignore[import-not-found]
|
||||
QueryEngineTool,
|
||||
)
|
||||
from llama_index.core.tools import QueryEngineTool
|
||||
|
||||
if not isinstance(query_engine, BaseQueryEngine):
|
||||
raise ValueError(f"Expected a BaseQueryEngine, got {type(query_engine)}")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.rag.data_types import DataType
|
||||
@@ -26,7 +28,7 @@ class MDXSearchTool(RagTool):
|
||||
)
|
||||
args_schema: type[BaseModel] = MDXSearchToolSchema
|
||||
|
||||
def __init__(self, mdx: str | None = None, **kwargs):
|
||||
def __init__(self, mdx: str | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if mdx is not None:
|
||||
self.add(mdx)
|
||||
@@ -34,7 +36,7 @@ class MDXSearchTool(RagTool):
|
||||
self.args_schema = FixedMDXSearchToolSchema
|
||||
self._generate_description()
|
||||
|
||||
def add(self, mdx: str) -> None:
|
||||
def add(self, mdx: str) -> None: # type: ignore[override]
|
||||
super().add(mdx, data_type=DataType.MDX)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai.tools import BaseTool, EnvVar
|
||||
@@ -108,7 +108,7 @@ class MergeAgentHandlerTool(BaseTool):
|
||||
)
|
||||
raise MergeAgentHandlerToolError(f"API Error: {error_msg}")
|
||||
|
||||
return result
|
||||
return cast(dict[str, Any], result)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to call Agent Handler API: {e!s}")
|
||||
@@ -219,8 +219,8 @@ class MergeAgentHandlerTool(BaseTool):
|
||||
required = params.get("required", [])
|
||||
|
||||
for field_name, field_schema in properties.items():
|
||||
field_type = Any # Default type
|
||||
field_default = ... # Required by default
|
||||
field_type: Any = Any
|
||||
field_default: Any = ...
|
||||
|
||||
# Map JSON schema types to Python types
|
||||
json_type = field_schema.get("type", "string")
|
||||
@@ -256,7 +256,7 @@ class MergeAgentHandlerTool(BaseTool):
|
||||
|
||||
# Create the Pydantic model
|
||||
if fields:
|
||||
args_schema = create_model(
|
||||
args_schema = create_model( # type: ignore[call-overload]
|
||||
f"{tool_name.replace('__', '_').title()}Args",
|
||||
**fields,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pymongo.collection import Collection
|
||||
from pymongo.collection import Collection as MongoCollection
|
||||
|
||||
|
||||
def _vector_search_index_definition(
|
||||
@@ -34,7 +34,7 @@ def _vector_search_index_definition(
|
||||
|
||||
|
||||
def create_vector_search_index(
|
||||
collection: Collection,
|
||||
collection: MongoCollection[Any],
|
||||
index_name: str,
|
||||
dimensions: int,
|
||||
path: str,
|
||||
@@ -84,7 +84,7 @@ def create_vector_search_index(
|
||||
)
|
||||
|
||||
|
||||
def _is_index_ready(collection: Collection, index_name: str) -> bool:
|
||||
def _is_index_ready(collection: MongoCollection[Any], index_name: str) -> bool:
|
||||
"""Check for the index name in the list of available search indexes to see if the
|
||||
specified index is of status READY.
|
||||
|
||||
@@ -102,7 +102,7 @@ def _is_index_ready(collection: Collection, index_name: str) -> bool:
|
||||
|
||||
|
||||
def _wait_for_predicate(
|
||||
predicate: Callable, err: str, timeout: float = 120, interval: float = 0.5
|
||||
predicate: Callable[[], bool], err: str, timeout: float = 120, interval: float = 0.5
|
||||
) -> None:
|
||||
"""Generic to block until the predicate returns true.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class MongoDBVectorSearchConfig(BaseModel):
|
||||
default=None,
|
||||
description="List of MQL match expressions comparing an indexed field",
|
||||
)
|
||||
post_filter_pipeline: list[dict] | None = Field(
|
||||
post_filter_pipeline: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Pipeline of MongoDB aggregation stages to filter/process results after $vectorSearch.",
|
||||
)
|
||||
@@ -105,7 +105,7 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
)
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["mongdb"])
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if not MONGODB_AVAILABLE:
|
||||
import click
|
||||
@@ -120,6 +120,7 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
else:
|
||||
raise ImportError("You are missing the 'mongodb' crewai tool.")
|
||||
|
||||
self._openai_client: AzureOpenAI | Client
|
||||
if "AZURE_OPENAI_ENDPOINT" in os.environ:
|
||||
self._openai_client = AzureOpenAI()
|
||||
elif "OPENAI_API_KEY" in os.environ:
|
||||
@@ -132,7 +133,7 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
from pymongo import MongoClient
|
||||
from pymongo.driver_info import DriverInfo
|
||||
|
||||
self._client = MongoClient(
|
||||
self._client: MongoClient[dict[str, Any]] = MongoClient(
|
||||
self.connection_string,
|
||||
driver=DriverInfo(name="CrewAI", version=version("crewai-tools")),
|
||||
)
|
||||
@@ -236,7 +237,7 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
def _bulk_embed_and_insert_texts(
|
||||
self,
|
||||
texts: list[str],
|
||||
metadatas: list[dict],
|
||||
metadatas: list[dict[str, Any]],
|
||||
ids: list[str],
|
||||
) -> list[str]:
|
||||
"""Bulk insert single batch of texts, embeddings, and ids."""
|
||||
@@ -315,16 +316,18 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
logger.error(f"Error: {e}")
|
||||
return ""
|
||||
|
||||
def __del__(self):
|
||||
def __del__(self) -> None:
|
||||
"""Cleanup clients on deletion."""
|
||||
try:
|
||||
if hasattr(self, "_client") and self._client:
|
||||
self._client.close()
|
||||
client: Any = getattr(self, "_client", None)
|
||||
if client is not None:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
try:
|
||||
if hasattr(self, "_openai_client") and self._openai_client:
|
||||
self._openai_client.close()
|
||||
openai_client: Any = getattr(self, "_openai_client", None)
|
||||
if openai_client is not None:
|
||||
openai_client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
@@ -31,11 +31,11 @@ class MultiOnTool(BaseTool):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
**kwargs,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
try:
|
||||
from multion.client import MultiOn # type: ignore
|
||||
from multion.client import MultiOn
|
||||
except ImportError:
|
||||
import click
|
||||
|
||||
@@ -78,4 +78,4 @@ class MultiOnTool(BaseTool):
|
||||
)
|
||||
self.session_id = browse.session_id
|
||||
|
||||
return browse.message + "\n\n STATUS: " + browse.status
|
||||
return str(browse.message) + "\n\n STATUS: " + str(browse.status)
|
||||
|
||||
@@ -21,13 +21,13 @@ class MySQLSearchTool(RagTool):
|
||||
args_schema: type[BaseModel] = MySQLSearchToolSchema
|
||||
db_uri: str = Field(..., description="Mandatory database URI")
|
||||
|
||||
def __init__(self, table_name: str, **kwargs):
|
||||
def __init__(self, table_name: str, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.add(table_name, data_type=DataType.MYSQL, metadata={"db_uri": self.db_uri})
|
||||
self.description = f"A tool that can be used to semantic search a query the {table_name} database table's content."
|
||||
self._generate_description()
|
||||
|
||||
def add(
|
||||
def add( # type: ignore[override]
|
||||
self,
|
||||
table_name: str,
|
||||
**kwargs: Any,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from collections.abc import Iterator
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self
|
||||
except ImportError:
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
try:
|
||||
@@ -12,6 +22,186 @@ try:
|
||||
except ImportError:
|
||||
SQLALCHEMY_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commands allowed in read-only mode
|
||||
# NOTE: WITH is intentionally excluded — writable CTEs start with WITH, so the
|
||||
# CTE body must be inspected separately (see _validate_statement).
|
||||
_READ_ONLY_COMMANDS = {"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"}
|
||||
|
||||
# Commands that mutate state and are blocked by default
|
||||
_WRITE_COMMANDS = {
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"TRUNCATE",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"EXEC",
|
||||
"EXECUTE",
|
||||
"CALL",
|
||||
"MERGE",
|
||||
"REPLACE",
|
||||
"UPSERT",
|
||||
"LOAD",
|
||||
"COPY",
|
||||
"VACUUM",
|
||||
"ANALYZE",
|
||||
"ANALYSE",
|
||||
"REINDEX",
|
||||
"CLUSTER",
|
||||
"REFRESH",
|
||||
"COMMENT",
|
||||
"SET",
|
||||
"RESET",
|
||||
}
|
||||
|
||||
|
||||
# Subset of write commands that can realistically appear *inside* a CTE body.
|
||||
# Narrower than _WRITE_COMMANDS to avoid false positives on identifiers like
|
||||
# ``comment``, ``set``, or ``reset`` which are common column/table names.
|
||||
_CTE_WRITE_INDICATORS = {
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"TRUNCATE",
|
||||
"MERGE",
|
||||
}
|
||||
|
||||
|
||||
_AS_PAREN_RE = re.compile(r"\bAS\s*\(", re.IGNORECASE)
|
||||
|
||||
|
||||
def _iter_as_paren_matches(stmt: str) -> Iterator[re.Match[str]]:
|
||||
"""Yield regex matches for ``AS\\s*(`` outside of string literals."""
|
||||
# Build a set of character positions that are inside string literals.
|
||||
in_string: set[int] = set()
|
||||
i = 0
|
||||
while i < len(stmt):
|
||||
if stmt[i] == "'":
|
||||
start = i
|
||||
end = _skip_string_literal(stmt, i)
|
||||
in_string.update(range(start, end))
|
||||
i = end
|
||||
else:
|
||||
i += 1
|
||||
|
||||
for m in _AS_PAREN_RE.finditer(stmt):
|
||||
if m.start() not in in_string:
|
||||
yield m
|
||||
|
||||
|
||||
def _detect_writable_cte(stmt: str) -> str | None:
|
||||
"""Return the first write command inside a CTE body, or None.
|
||||
|
||||
Instead of tokenizing the whole statement (which falsely matches column
|
||||
names like ``comment``), this walks through parenthesized CTE bodies and
|
||||
checks only the *first keyword after* an opening ``AS (`` for a write
|
||||
command. Uses a regex to handle any whitespace (spaces, tabs, newlines)
|
||||
between ``AS`` and ``(``. Skips matches inside string literals.
|
||||
"""
|
||||
for m in _iter_as_paren_matches(stmt):
|
||||
body = stmt[m.end() :].lstrip()
|
||||
first_word = body.split()[0].upper().strip("()") if body.split() else ""
|
||||
if first_word in _CTE_WRITE_INDICATORS:
|
||||
return first_word
|
||||
return None
|
||||
|
||||
|
||||
def _skip_string_literal(stmt: str, pos: int) -> int:
|
||||
"""Skip past a string literal starting at pos (single-quoted).
|
||||
|
||||
Handles escaped quotes ('') inside the literal.
|
||||
Returns the index after the closing quote.
|
||||
"""
|
||||
quote_char = stmt[pos]
|
||||
i = pos + 1
|
||||
while i < len(stmt):
|
||||
if stmt[i] == quote_char:
|
||||
# Check for escaped quote ('')
|
||||
if i + 1 < len(stmt) and stmt[i + 1] == quote_char:
|
||||
i += 2
|
||||
continue
|
||||
return i + 1
|
||||
i += 1
|
||||
return i # Unterminated literal — return end
|
||||
|
||||
|
||||
def _find_matching_close_paren(stmt: str, start: int) -> int:
|
||||
"""Find the matching close paren, skipping string literals."""
|
||||
depth = 1
|
||||
i = start
|
||||
while i < len(stmt) and depth > 0:
|
||||
ch = stmt[i]
|
||||
if ch == "'":
|
||||
i = _skip_string_literal(stmt, i)
|
||||
continue
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _extract_main_query_after_cte(stmt: str) -> str | None:
|
||||
"""Extract the main (outer) query that follows all CTE definitions.
|
||||
|
||||
For ``WITH cte AS (SELECT 1) DELETE FROM users``, returns ``DELETE FROM users``.
|
||||
Returns None if no main query is found after the last CTE body.
|
||||
Handles parentheses inside string literals (e.g., ``SELECT '(' FROM t``).
|
||||
"""
|
||||
last_cte_end = 0
|
||||
for m in _iter_as_paren_matches(stmt):
|
||||
last_cte_end = _find_matching_close_paren(stmt, m.end())
|
||||
|
||||
if last_cte_end > 0:
|
||||
remainder = stmt[last_cte_end:].strip().lstrip(",").strip()
|
||||
if remainder:
|
||||
return remainder
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_explain_command(stmt: str) -> str | None:
|
||||
"""Resolve the underlying command from an EXPLAIN [ANALYZE] [VERBOSE] statement.
|
||||
|
||||
Returns the real command (e.g., 'DELETE') if ANALYZE is present, else None.
|
||||
Handles both space-separated and parenthesized syntax.
|
||||
"""
|
||||
rest = stmt.strip()[len("EXPLAIN") :].strip()
|
||||
if not rest:
|
||||
return None
|
||||
|
||||
analyze_found = False
|
||||
explain_opts = {"ANALYZE", "ANALYSE", "VERBOSE"}
|
||||
|
||||
if rest.startswith("("):
|
||||
close = rest.find(")")
|
||||
if close != -1:
|
||||
options_str = rest[1:close].upper()
|
||||
analyze_found = any(
|
||||
opt.strip() in ("ANALYZE", "ANALYSE") for opt in options_str.split(",")
|
||||
)
|
||||
rest = rest[close + 1 :].strip()
|
||||
else:
|
||||
while rest:
|
||||
first_opt = rest.split()[0].upper().rstrip(";") if rest.split() else ""
|
||||
if first_opt in ("ANALYZE", "ANALYSE"):
|
||||
analyze_found = True
|
||||
if first_opt not in explain_opts:
|
||||
break
|
||||
rest = rest[len(first_opt) :].strip()
|
||||
|
||||
if analyze_found and rest:
|
||||
return rest.split()[0].upper().rstrip(";")
|
||||
return None
|
||||
|
||||
|
||||
class NL2SQLToolInput(BaseModel):
|
||||
sql_query: str = Field(
|
||||
@@ -21,24 +211,77 @@ class NL2SQLToolInput(BaseModel):
|
||||
|
||||
|
||||
class NL2SQLTool(BaseTool):
|
||||
"""Tool that converts natural language to SQL and executes it against a database.
|
||||
|
||||
By default the tool operates in **read-only mode**: only SELECT, SHOW,
|
||||
DESCRIBE, EXPLAIN, and read-only CTEs (WITH … SELECT) are permitted. Write
|
||||
operations (INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, …) are
|
||||
blocked unless ``allow_dml=True`` is set explicitly or the environment
|
||||
variable ``CREWAI_NL2SQL_ALLOW_DML=true`` is present.
|
||||
|
||||
Writable CTEs (``WITH d AS (DELETE …) SELECT …``) and
|
||||
``EXPLAIN ANALYZE <write-stmt>`` are treated as write operations and are
|
||||
blocked in read-only mode.
|
||||
|
||||
The ``_fetch_all_available_columns`` helper uses parameterised queries so
|
||||
that table names coming from the database catalogue cannot be used as an
|
||||
injection vector.
|
||||
"""
|
||||
|
||||
name: str = "NL2SQLTool"
|
||||
description: str = "Converts natural language to SQL queries and executes them."
|
||||
description: str = (
|
||||
"Converts natural language to SQL queries and executes them against a "
|
||||
"database. Read-only by default — only SELECT/SHOW/DESCRIBE/EXPLAIN "
|
||||
"queries (and read-only CTEs) are allowed unless configured with "
|
||||
"allow_dml=True."
|
||||
)
|
||||
db_uri: str = Field(
|
||||
title="Database URI",
|
||||
description="The URI of the database to connect to.",
|
||||
)
|
||||
tables: list = Field(default_factory=list)
|
||||
columns: dict = Field(default_factory=dict)
|
||||
allow_dml: bool = Field(
|
||||
default=False,
|
||||
title="Allow DML",
|
||||
description=(
|
||||
"When False (default) only read statements are permitted. "
|
||||
"Set to True to allow INSERT/UPDATE/DELETE/DROP and other "
|
||||
"write operations."
|
||||
),
|
||||
)
|
||||
tables: list[dict[str, Any]] = Field(default_factory=list)
|
||||
columns: dict[str, list[dict[str, Any]] | str] = Field(default_factory=dict)
|
||||
args_schema: type[BaseModel] = NL2SQLToolInput
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _apply_env_override(self) -> Self:
|
||||
"""Allow CREWAI_NL2SQL_ALLOW_DML=true to override allow_dml at runtime."""
|
||||
if os.environ.get("CREWAI_NL2SQL_ALLOW_DML", "").strip().lower() == "true":
|
||||
if not self.allow_dml:
|
||||
logger.warning(
|
||||
"NL2SQLTool: CREWAI_NL2SQL_ALLOW_DML env var is set — "
|
||||
"DML/DDL operations are enabled. Ensure this is intentional."
|
||||
)
|
||||
self.allow_dml = True
|
||||
return self
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
if not SQLALCHEMY_AVAILABLE:
|
||||
raise ImportError(
|
||||
"sqlalchemy is not installed. Please install it with `pip install crewai-tools[sqlalchemy]`"
|
||||
"sqlalchemy is not installed. Please install it with "
|
||||
"`pip install crewai-tools[sqlalchemy]`"
|
||||
)
|
||||
|
||||
data = {}
|
||||
tables = self._fetch_available_tables()
|
||||
if self.allow_dml:
|
||||
logger.warning(
|
||||
"NL2SQLTool: allow_dml=True — write operations (INSERT/UPDATE/"
|
||||
"DELETE/DROP/…) are permitted. Use with caution."
|
||||
)
|
||||
|
||||
data: dict[str, list[dict[str, Any]] | str] = {}
|
||||
result = self._fetch_available_tables()
|
||||
if isinstance(result, str):
|
||||
raise RuntimeError(f"Failed to fetch tables: {result}")
|
||||
tables: list[dict[str, Any]] = result
|
||||
|
||||
for table in tables:
|
||||
table_columns = self._fetch_all_available_columns(table["table_name"])
|
||||
@@ -47,40 +290,216 @@ class NL2SQLTool(BaseTool):
|
||||
self.tables = tables
|
||||
self.columns = data
|
||||
|
||||
def _fetch_available_tables(self):
|
||||
# ------------------------------------------------------------------
|
||||
# Query validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_query(self, sql_query: str) -> None:
|
||||
"""Raise ValueError if *sql_query* is not permitted under the current config.
|
||||
|
||||
Splits the query on semicolons and validates each statement
|
||||
independently. When ``allow_dml=False`` (the default), multi-statement
|
||||
queries are rejected outright to prevent ``SELECT 1; DROP TABLE users``
|
||||
style bypasses. When ``allow_dml=True`` every statement is checked and
|
||||
a warning is emitted for write operations.
|
||||
"""
|
||||
statements = [s.strip() for s in sql_query.split(";") if s.strip()]
|
||||
|
||||
if not statements:
|
||||
raise ValueError("NL2SQLTool received an empty SQL query.")
|
||||
|
||||
if not self.allow_dml and len(statements) > 1:
|
||||
raise ValueError(
|
||||
"NL2SQLTool blocked a multi-statement query in read-only mode. "
|
||||
"Semicolons are not permitted when allow_dml=False."
|
||||
)
|
||||
|
||||
for stmt in statements:
|
||||
self._validate_statement(stmt)
|
||||
|
||||
def _validate_statement(self, stmt: str) -> None:
|
||||
"""Validate a single SQL statement (no semicolons)."""
|
||||
command = self._extract_command(stmt)
|
||||
|
||||
# EXPLAIN ANALYZE / EXPLAIN ANALYSE actually *executes* the underlying
|
||||
# query. Resolve the real command so write operations are caught.
|
||||
# Handles both space-separated ("EXPLAIN ANALYZE DELETE …") and
|
||||
# parenthesized ("EXPLAIN (ANALYZE) DELETE …", "EXPLAIN (ANALYZE, VERBOSE) DELETE …").
|
||||
# EXPLAIN ANALYZE actually executes the underlying query — resolve the
|
||||
# real command so write operations are caught.
|
||||
if command == "EXPLAIN":
|
||||
resolved = _resolve_explain_command(stmt)
|
||||
if resolved:
|
||||
command = resolved
|
||||
|
||||
# WITH starts a CTE. Read-only CTEs are fine; writable CTEs
|
||||
# (e.g. WITH d AS (DELETE …) SELECT …) must be blocked in read-only mode.
|
||||
if command == "WITH":
|
||||
# Check for write commands inside CTE bodies.
|
||||
write_found = _detect_writable_cte(stmt)
|
||||
if write_found:
|
||||
found = write_found
|
||||
if not self.allow_dml:
|
||||
raise ValueError(
|
||||
f"NL2SQLTool is configured in read-only mode and blocked a "
|
||||
f"writable CTE containing a '{found}' statement. To allow "
|
||||
f"write operations set allow_dml=True or "
|
||||
f"CREWAI_NL2SQL_ALLOW_DML=true."
|
||||
)
|
||||
logger.warning(
|
||||
"NL2SQLTool: executing writable CTE with '%s' because allow_dml=True.",
|
||||
found,
|
||||
)
|
||||
return
|
||||
|
||||
# Check the main query after the CTE definitions.
|
||||
main_query = _extract_main_query_after_cte(stmt)
|
||||
if main_query:
|
||||
main_cmd = main_query.split()[0].upper().rstrip(";")
|
||||
if main_cmd in _WRITE_COMMANDS:
|
||||
if not self.allow_dml:
|
||||
raise ValueError(
|
||||
f"NL2SQLTool is configured in read-only mode and blocked a "
|
||||
f"'{main_cmd}' statement after a CTE. To allow write "
|
||||
f"operations set allow_dml=True or "
|
||||
f"CREWAI_NL2SQL_ALLOW_DML=true."
|
||||
)
|
||||
logger.warning(
|
||||
"NL2SQLTool: executing '%s' after CTE because allow_dml=True.",
|
||||
main_cmd,
|
||||
)
|
||||
elif main_cmd not in _READ_ONLY_COMMANDS:
|
||||
if not self.allow_dml:
|
||||
raise ValueError(
|
||||
f"NL2SQLTool blocked an unrecognised SQL command '{main_cmd}' "
|
||||
f"after a CTE. Only {sorted(_READ_ONLY_COMMANDS)} are allowed "
|
||||
f"in read-only mode."
|
||||
)
|
||||
return
|
||||
|
||||
if command in _WRITE_COMMANDS:
|
||||
if not self.allow_dml:
|
||||
raise ValueError(
|
||||
f"NL2SQLTool is configured in read-only mode and blocked a "
|
||||
f"'{command}' statement. To allow write operations set "
|
||||
f"allow_dml=True or CREWAI_NL2SQL_ALLOW_DML=true."
|
||||
)
|
||||
logger.warning(
|
||||
"NL2SQLTool: executing write statement '%s' because allow_dml=True.",
|
||||
command,
|
||||
)
|
||||
elif command not in _READ_ONLY_COMMANDS:
|
||||
# Unknown command — block by default unless DML is explicitly enabled
|
||||
if not self.allow_dml:
|
||||
raise ValueError(
|
||||
f"NL2SQLTool blocked an unrecognised SQL command '{command}'. "
|
||||
f"Only {sorted(_READ_ONLY_COMMANDS)} are allowed in read-only "
|
||||
f"mode."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_command(sql_query: str) -> str:
|
||||
"""Return the uppercased first keyword of *sql_query*."""
|
||||
stripped = sql_query.strip().lstrip("(")
|
||||
first_token = stripped.split()[0] if stripped.split() else ""
|
||||
return first_token.upper().rstrip(";")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schema introspection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fetch_available_tables(self) -> list[dict[str, Any]] | str:
|
||||
return self.execute_sql(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';"
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public';"
|
||||
)
|
||||
|
||||
def _fetch_all_available_columns(self, table_name: str):
|
||||
def _fetch_all_available_columns(
|
||||
self, table_name: str
|
||||
) -> list[dict[str, Any]] | str:
|
||||
"""Fetch columns for *table_name* using a parameterised query.
|
||||
|
||||
The table name is bound via SQLAlchemy's ``:param`` syntax to prevent
|
||||
SQL injection from catalogue values.
|
||||
"""
|
||||
return self.execute_sql(
|
||||
f"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '{table_name}';" # noqa: S608
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_name = :table_name",
|
||||
params={"table_name": table_name},
|
||||
)
|
||||
|
||||
def _run(self, sql_query: str):
|
||||
# ------------------------------------------------------------------
|
||||
# Core execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run(self, sql_query: str) -> list[dict[str, Any]] | str:
|
||||
try:
|
||||
self._validate_query(sql_query)
|
||||
data = self.execute_sql(sql_query)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
data = (
|
||||
f"Based on these tables {self.tables} and columns {self.columns}, "
|
||||
"you can create SQL queries to retrieve data from the database."
|
||||
f"Get the original request {sql_query} and the error {exc} and create the correct SQL query."
|
||||
"you can create SQL queries to retrieve data from the database. "
|
||||
f"Get the original request {sql_query} and the error {exc} and "
|
||||
"create the correct SQL query."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def execute_sql(self, sql_query: str) -> list | str:
|
||||
def execute_sql(
|
||||
self,
|
||||
sql_query: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]] | str:
|
||||
"""Execute *sql_query* and return the results as a list of dicts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sql_query:
|
||||
The SQL statement to run.
|
||||
params:
|
||||
Optional mapping of bind parameters (e.g. ``{"table_name": "users"}``).
|
||||
"""
|
||||
if not SQLALCHEMY_AVAILABLE:
|
||||
raise ImportError(
|
||||
"sqlalchemy is not installed. Please install it with `pip install crewai-tools[sqlalchemy]`"
|
||||
"sqlalchemy is not installed. Please install it with "
|
||||
"`pip install crewai-tools[sqlalchemy]`"
|
||||
)
|
||||
|
||||
# Check ALL statements so that e.g. "SELECT 1; DROP TABLE t" triggers a
|
||||
# commit when allow_dml=True, regardless of statement order.
|
||||
_stmts = [s.strip() for s in sql_query.split(";") if s.strip()]
|
||||
|
||||
def _is_write_stmt(s: str) -> bool:
|
||||
cmd = self._extract_command(s)
|
||||
if cmd in _WRITE_COMMANDS:
|
||||
return True
|
||||
if cmd == "EXPLAIN":
|
||||
# Resolve the underlying command for EXPLAIN ANALYZE
|
||||
resolved = _resolve_explain_command(s)
|
||||
if resolved and resolved in _WRITE_COMMANDS:
|
||||
return True
|
||||
if cmd == "WITH":
|
||||
if _detect_writable_cte(s):
|
||||
return True
|
||||
main_q = _extract_main_query_after_cte(s)
|
||||
if main_q:
|
||||
return main_q.split()[0].upper().rstrip(";") in _WRITE_COMMANDS
|
||||
return False
|
||||
|
||||
is_write = any(_is_write_stmt(s) for s in _stmts)
|
||||
|
||||
engine = create_engine(self.db_uri)
|
||||
Session = sessionmaker(bind=engine) # noqa: N806
|
||||
session = Session()
|
||||
try:
|
||||
result = session.execute(text(sql_query))
|
||||
session.commit()
|
||||
result = session.execute(text(sql_query), params or {})
|
||||
|
||||
# Only commit when the operation actually mutates state
|
||||
if self.allow_dml and is_write:
|
||||
session.commit()
|
||||
|
||||
if result.returns_rows: # type: ignore[attr-defined]
|
||||
columns = result.keys()
|
||||
|
||||
@@ -4,12 +4,15 @@ This tool provides functionality for extracting text from images using supported
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from crewai.llm import LLM
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
from crewai.utilities.types import LLMMessage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
|
||||
|
||||
class OCRToolSchema(BaseModel):
|
||||
"""Input schema for Optical Character Recognition Tool.
|
||||
@@ -43,7 +46,7 @@ class OCRTool(BaseTool):
|
||||
llm: LLM = Field(default_factory=lambda: LLM(model="gpt-4o", temperature=0.7))
|
||||
args_schema: type[BaseModel] = OCRToolSchema
|
||||
|
||||
def _run(self, **kwargs) -> str:
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
"""Execute the OCR operation on the provided image.
|
||||
|
||||
Args:
|
||||
@@ -88,7 +91,7 @@ class OCRTool(BaseTool):
|
||||
return self.llm.call(messages=messages)
|
||||
|
||||
@staticmethod
|
||||
def _encode_image(image_path: str):
|
||||
def _encode_image(image_path: str) -> str:
|
||||
"""Encode an image file to base64 format.
|
||||
|
||||
Args:
|
||||
@@ -97,5 +100,6 @@ class OCRTool(BaseTool):
|
||||
Returns:
|
||||
str: Base64-encoded image data as a UTF-8 string.
|
||||
"""
|
||||
image_path = validate_file_path(image_path)
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode()
|
||||
|
||||
@@ -41,12 +41,12 @@ class OxylabsAmazonProductScraperConfig(BaseModel):
|
||||
user_agent_type: str | None = Field(None, description="Device type and browser.")
|
||||
render: str | None = Field(None, description="Enables JavaScript rendering.")
|
||||
callback_url: str | None = Field(None, description="URL to your callback endpoint.")
|
||||
context: list | None = Field(
|
||||
context: list[Any] | None = Field(
|
||||
None,
|
||||
description="Additional advanced settings and controls for specialized requirements.",
|
||||
)
|
||||
parse: bool | None = Field(None, description="True will return structured data.")
|
||||
parsing_instructions: dict | None = Field(
|
||||
parsing_instructions: dict[str, Any] | None = Field(
|
||||
None, description="Instructions for parsing the results."
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ class OxylabsAmazonProductScraperTool(BaseTool):
|
||||
description: str = "Scrape Amazon product pages with Oxylabs Amazon Product Scraper"
|
||||
args_schema: type[BaseModel] = OxylabsAmazonProductScraperArgs
|
||||
|
||||
oxylabs_api: RealtimeClient
|
||||
oxylabs_api: Any
|
||||
config: OxylabsAmazonProductScraperConfig
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["oxylabs"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
@@ -93,8 +93,8 @@ class OxylabsAmazonProductScraperTool(BaseTool):
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
config: OxylabsAmazonProductScraperConfig | dict | None = None,
|
||||
**kwargs,
|
||||
config: OxylabsAmazonProductScraperConfig | dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
bits, _ = architecture()
|
||||
sdk_type = (
|
||||
@@ -164,4 +164,4 @@ class OxylabsAmazonProductScraperTool(BaseTool):
|
||||
if isinstance(content, dict):
|
||||
return json.dumps(content)
|
||||
|
||||
return content
|
||||
return str(content)
|
||||
|
||||
@@ -43,12 +43,12 @@ class OxylabsAmazonSearchScraperConfig(BaseModel):
|
||||
user_agent_type: str | None = Field(None, description="Device type and browser.")
|
||||
render: str | None = Field(None, description="Enables JavaScript rendering.")
|
||||
callback_url: str | None = Field(None, description="URL to your callback endpoint.")
|
||||
context: list | None = Field(
|
||||
context: list[Any] | None = Field(
|
||||
None,
|
||||
description="Additional advanced settings and controls for specialized requirements.",
|
||||
)
|
||||
parse: bool | None = Field(None, description="True will return structured data.")
|
||||
parsing_instructions: dict | None = Field(
|
||||
parsing_instructions: dict[str, Any] | None = Field(
|
||||
None, description="Instructions for parsing the results."
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ class OxylabsAmazonSearchScraperTool(BaseTool):
|
||||
description: str = "Scrape Amazon search results with Oxylabs Amazon Search Scraper"
|
||||
args_schema: type[BaseModel] = OxylabsAmazonSearchScraperArgs
|
||||
|
||||
oxylabs_api: RealtimeClient
|
||||
oxylabs_api: Any
|
||||
config: OxylabsAmazonSearchScraperConfig
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["oxylabs"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
@@ -95,9 +95,9 @@ class OxylabsAmazonSearchScraperTool(BaseTool):
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
config: OxylabsAmazonSearchScraperConfig | dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
config: OxylabsAmazonSearchScraperConfig | dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
bits, _ = architecture()
|
||||
sdk_type = (
|
||||
f"oxylabs-crewai-sdk-python/"
|
||||
@@ -166,4 +166,4 @@ class OxylabsAmazonSearchScraperTool(BaseTool):
|
||||
if isinstance(content, dict):
|
||||
return json.dumps(content)
|
||||
|
||||
return content
|
||||
return str(content)
|
||||
|
||||
@@ -46,12 +46,12 @@ class OxylabsGoogleSearchScraperConfig(BaseModel):
|
||||
user_agent_type: str | None = Field(None, description="Device type and browser.")
|
||||
render: str | None = Field(None, description="Enables JavaScript rendering.")
|
||||
callback_url: str | None = Field(None, description="URL to your callback endpoint.")
|
||||
context: list | None = Field(
|
||||
context: list[Any] | None = Field(
|
||||
None,
|
||||
description="Additional advanced settings and controls for specialized requirements.",
|
||||
)
|
||||
parse: bool | None = Field(None, description="True will return structured data.")
|
||||
parsing_instructions: dict | None = Field(
|
||||
parsing_instructions: dict[str, Any] | None = Field(
|
||||
None, description="Instructions for parsing the results."
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ class OxylabsGoogleSearchScraperTool(BaseTool):
|
||||
description: str = "Scrape Google Search results with Oxylabs Google Search Scraper"
|
||||
args_schema: type[BaseModel] = OxylabsGoogleSearchScraperArgs
|
||||
|
||||
oxylabs_api: RealtimeClient
|
||||
oxylabs_api: Any
|
||||
config: OxylabsGoogleSearchScraperConfig
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["oxylabs"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
@@ -98,9 +98,9 @@ class OxylabsGoogleSearchScraperTool(BaseTool):
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
config: OxylabsGoogleSearchScraperConfig | dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
config: OxylabsGoogleSearchScraperConfig | dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
bits, _ = architecture()
|
||||
sdk_type = (
|
||||
f"oxylabs-crewai-sdk-python/"
|
||||
@@ -158,7 +158,7 @@ class OxylabsGoogleSearchScraperTool(BaseTool):
|
||||
)
|
||||
return username, password
|
||||
|
||||
def _run(self, query: str, **kwargs) -> str:
|
||||
def _run(self, query: str, **kwargs: Any) -> str:
|
||||
response = self.oxylabs_api.google.scrape_search(
|
||||
query,
|
||||
**self.config.model_dump(exclude_none=True),
|
||||
@@ -169,4 +169,4 @@ class OxylabsGoogleSearchScraperTool(BaseTool):
|
||||
if isinstance(content, dict):
|
||||
return json.dumps(content)
|
||||
|
||||
return content
|
||||
return str(content)
|
||||
|
||||
@@ -37,12 +37,12 @@ class OxylabsUniversalScraperConfig(BaseModel):
|
||||
user_agent_type: str | None = Field(None, description="Device type and browser.")
|
||||
render: str | None = Field(None, description="Enables JavaScript rendering.")
|
||||
callback_url: str | None = Field(None, description="URL to your callback endpoint.")
|
||||
context: list | None = Field(
|
||||
context: list[Any] | None = Field(
|
||||
None,
|
||||
description="Additional advanced settings and controls for specialized requirements.",
|
||||
)
|
||||
parse: bool | None = Field(None, description="True will return structured data.")
|
||||
parsing_instructions: dict | None = Field(
|
||||
parsing_instructions: dict[str, Any] | None = Field(
|
||||
None, description="Instructions for parsing the results."
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ class OxylabsUniversalScraperTool(BaseTool):
|
||||
description: str = "Scrape any url with Oxylabs Universal Scraper"
|
||||
args_schema: type[BaseModel] = OxylabsUniversalScraperArgs
|
||||
|
||||
oxylabs_api: RealtimeClient
|
||||
oxylabs_api: Any
|
||||
config: OxylabsUniversalScraperConfig
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["oxylabs"])
|
||||
env_vars: list[EnvVar] = Field(
|
||||
@@ -89,9 +89,9 @@ class OxylabsUniversalScraperTool(BaseTool):
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
config: OxylabsUniversalScraperConfig | dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
config: OxylabsUniversalScraperConfig | dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
bits, _ = architecture()
|
||||
sdk_type = (
|
||||
f"oxylabs-crewai-sdk-python/"
|
||||
@@ -160,4 +160,4 @@ class OxylabsUniversalScraperTool(BaseTool):
|
||||
if isinstance(content, dict):
|
||||
return json.dumps(content)
|
||||
|
||||
return content
|
||||
return str(content)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from crewai import Agent, Crew, Task
|
||||
from patronus import ( # type: ignore[import-not-found,import-untyped]
|
||||
from patronus import ( # type: ignore[import-untyped]
|
||||
Client,
|
||||
EvaluationResult,
|
||||
)
|
||||
from patronus_local_evaluator_tool import ( # type: ignore[import-not-found,import-untyped]
|
||||
from patronus_local_evaluator_tool import ( # type: ignore[import-not-found]
|
||||
PatronusLocalEvaluatorTool,
|
||||
)
|
||||
|
||||
@@ -15,8 +16,8 @@ client = Client()
|
||||
|
||||
|
||||
# Example of an evaluator that returns a random pass/fail result
|
||||
@client.register_local_evaluator("random_evaluator")
|
||||
def random_evaluator(**kwargs):
|
||||
@client.register_local_evaluator("random_evaluator") # type: ignore[untyped-decorator]
|
||||
def random_evaluator(**kwargs: Any) -> Any:
|
||||
score = random.random() # noqa: S311
|
||||
return EvaluationResult(
|
||||
score_raw=score,
|
||||
|
||||
@@ -34,7 +34,7 @@ class PatronusEvalTool(BaseTool):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def _init_run(self):
|
||||
def _init_run(self) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
evaluators_set = json.loads(
|
||||
requests.get(
|
||||
"https://api.patronus.ai/v1/evaluators",
|
||||
@@ -136,8 +136,9 @@ class PatronusEvalTool(BaseTool):
|
||||
"evaluators": evals,
|
||||
}
|
||||
|
||||
api_key = os.getenv("PATRONUS_API_KEY", "")
|
||||
headers = {
|
||||
"X-API-KEY": os.getenv("PATRONUS_API_KEY"),
|
||||
"X-API-KEY": api_key,
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from patronus import Client, EvaluationResult # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
import patronus # noqa: F401
|
||||
import patronus # type: ignore[import-untyped] # noqa: F401
|
||||
|
||||
PYPATRONUS_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -37,7 +34,7 @@ class PatronusLocalEvaluatorTool(BaseTool):
|
||||
name: str = "Patronus Local Evaluator Tool"
|
||||
description: str = "This tool is used to evaluate the model input and output using custom function evaluators."
|
||||
args_schema: type[BaseModel] = FixedLocalEvaluatorToolSchema
|
||||
client: Client = None
|
||||
client: Any = None
|
||||
evaluator: str
|
||||
evaluated_model_gold_answer: str
|
||||
|
||||
@@ -46,7 +43,7 @@ class PatronusLocalEvaluatorTool(BaseTool):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patronus_client: Client = None,
|
||||
patronus_client: Any = None,
|
||||
evaluator: str = "",
|
||||
evaluated_model_gold_answer: str = "",
|
||||
**kwargs: Any,
|
||||
@@ -56,7 +53,7 @@ class PatronusLocalEvaluatorTool(BaseTool):
|
||||
self.evaluated_model_gold_answer = evaluated_model_gold_answer
|
||||
self._initialize_patronus(patronus_client)
|
||||
|
||||
def _initialize_patronus(self, patronus_client: Client) -> None:
|
||||
def _initialize_patronus(self, patronus_client: Any) -> None:
|
||||
try:
|
||||
if PYPATRONUS_AVAILABLE:
|
||||
self.client = patronus_client
|
||||
@@ -94,7 +91,7 @@ class PatronusLocalEvaluatorTool(BaseTool):
|
||||
evaluated_model_gold_answer = self.evaluated_model_gold_answer
|
||||
evaluator = self.evaluator
|
||||
|
||||
result: EvaluationResult = self.client.evaluate(
|
||||
result: Any = self.client.evaluate(
|
||||
evaluator=evaluator,
|
||||
evaluated_model_input=evaluated_model_input,
|
||||
evaluated_model_output=evaluated_model_output,
|
||||
|
||||
@@ -8,16 +8,16 @@ import requests
|
||||
|
||||
|
||||
class FixedBaseToolSchema(BaseModel):
|
||||
evaluated_model_input: dict = Field(
|
||||
evaluated_model_input: dict[str, Any] = Field(
|
||||
..., description="The agent's task description in simple text"
|
||||
)
|
||||
evaluated_model_output: dict = Field(
|
||||
evaluated_model_output: dict[str, Any] = Field(
|
||||
..., description="The agent's output of the task"
|
||||
)
|
||||
evaluated_model_retrieved_context: dict = Field(
|
||||
evaluated_model_retrieved_context: dict[str, Any] = Field(
|
||||
..., description="The agent's context"
|
||||
)
|
||||
evaluated_model_gold_answer: dict = Field(
|
||||
evaluated_model_gold_answer: dict[str, Any] = Field(
|
||||
..., description="The agent's gold answer only if available"
|
||||
)
|
||||
evaluators: list[dict[str, str]] = Field(
|
||||
@@ -57,8 +57,9 @@ class PatronusPredefinedCriteriaEvalTool(BaseTool):
|
||||
evaluated_model_gold_answer = kwargs.get("evaluated_model_gold_answer")
|
||||
evaluators = self.evaluators
|
||||
|
||||
api_key = os.getenv("PATRONUS_API_KEY", "")
|
||||
headers = {
|
||||
"X-API-KEY": os.getenv("PATRONUS_API_KEY"),
|
||||
"X-API-KEY": api_key,
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class PDFSearchTool(RagTool):
|
||||
self._generate_description()
|
||||
return self
|
||||
|
||||
def add(self, pdf: str) -> None:
|
||||
def add(self, pdf: str) -> None: # type: ignore[override]
|
||||
super().add(pdf, data_type=DataType.PDF_FILE)
|
||||
|
||||
def _run( # type: ignore[override]
|
||||
|
||||
@@ -119,7 +119,7 @@ class QdrantVectorSearchTool(BaseTool):
|
||||
)
|
||||
)()
|
||||
)
|
||||
results = self.client.query_points(
|
||||
results = self.client.query_points( # type: ignore[union-attr]
|
||||
collection_name=self.qdrant_config.collection_name,
|
||||
query=query_vector,
|
||||
query_filter=search_filter,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user