Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ print(DecodoWebScraperTool().run(url="https://ip.decodo.com"))
print(DecodoSearchTool().run(query="mechanical keyboard", source="amazon"))
```

For a complete multi-agent example, see the [research crew](examples/research_crew): a researcher, an analyst and a writer that turn any topic into a cited report.
For a complete multi-agent example, see the [research crew](https://github.com/Decodo/crewAI/tree/main/examples/research_crew): a researcher, an analyst and a writer that turn any topic into a cited report.

## Tools

Expand Down Expand Up @@ -127,7 +127,7 @@ Bing results are rendered in a headless browser and can take over a minute.

## Errors

When a request fails, for example because of an invalid API key, a rate limit or a page that returns `404`, the tools return a CrewAI `ToolFailure` instead of raising. The agent sees what went wrong and can retry or change course, and CrewAI records the failure according to the agent's `tool_failure_policy`.
When a request fails, for example because of an invalid API key, a rate limit, a page that returns `404` or a network error that keeps the request from reaching Decodo, the tools return a CrewAI `ToolFailure` instead of raising. The agent sees what went wrong and can retry or change course, and CrewAI records the failure according to the agent's `tool_failure_policy`.

## License

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
# ToolFailure first shipped in 1.15.9.
"crewai>=1.15.9",
"decodo-sdk>=2.4.1",
"httpx>=0.27.0",
]

[project.optional-dependencies]
Expand Down
10 changes: 9 additions & 1 deletion src/decodo_crewai/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from typing import Any

import httpx
from crewai.tools import BaseTool, EnvVar
from crewai.tools.tool_failure import ToolFailure
from decodo import (
Expand Down Expand Up @@ -44,7 +45,7 @@ class DecodoBaseTool(BaseTool):

def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
api_key = (api_key or os.environ.get(API_KEY_ENV_VAR) or "").strip()
if not api_key:
raise ValueError(f"Pass api_key or set the {API_KEY_ENV_VAR} environment variable.")
self._client = DecodoClient(
Expand Down Expand Up @@ -74,6 +75,13 @@ def _scrape(self, params: ScrapeRequest) -> Any:
code=str(e.status_code),
retryable=e.status_code >= 500,
)
except (httpx.LocalProtocolError, UnicodeEncodeError):
return ToolFailure(
message=f"The request to Decodo could not be sent. Check {API_KEY_ENV_VAR} for invalid characters.",
code="invalid_request",
)
except httpx.TransportError as e:
return ToolFailure(message=f"Could not reach Decodo: {e}", code="network_error", retryable=True)

results = response.get("results") or []
if not results:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
DecodoWebScraperTool()


def test_blank_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DECODO_API_KEY", " \n")
with pytest.raises(ValueError, match="DECODO_API_KEY"):
DecodoWebScraperTool()


@pytest.mark.parametrize("api_key", ["padded-key\n", " padded-key "])
def test_api_key_whitespace_is_stripped(fake_api: FakeApi, monkeypatch: pytest.MonkeyPatch, api_key: str) -> None:
monkeypatch.setenv("DECODO_API_KEY", api_key)
DecodoWebScraperTool().run(url="https://example.com")
DecodoWebScraperTool(api_key=api_key).run(url="https://example.com")
assert [call["headers"]["Authorization"] for call in fake_api.calls] == ["Bearer padded-key"] * 2


def test_api_key_argument_overrides_env(fake_api: FakeApi) -> None:
DecodoWebScraperTool(api_key="explicit-key").run(url="https://example.com")
assert fake_api.calls[0]["headers"]["Authorization"] == "Bearer explicit-key"
Expand Down Expand Up @@ -123,6 +137,45 @@ def test_api_errors_become_tool_failures(fake_api: FakeApi, status_code: int, co
assert result.retryable is retryable


def test_network_errors_become_retryable_tool_failures(monkeypatch: pytest.MonkeyPatch) -> None:
def refuse(*args: Any, **kwargs: Any) -> httpx.Response:
raise httpx.ConnectError("Connection refused")

monkeypatch.setattr("decodo.http.httpx.request", refuse)

for tool, kwargs in [
(DecodoWebScraperTool(), {"url": "https://example.com"}),
(DecodoSearchTool(), {"query": "coffee"}),
(DecodoEcommerceTool(), {"store": "amazon", "query": "coffee"}),
(DecodoSERPTool(), {"query": "coffee"}),
]:
result = tool.run(**kwargs)
assert isinstance(result, ToolFailure)
assert result.code == "network_error"
assert result.retryable is True
assert "Connection refused" in result.message


@pytest.mark.parametrize(
"error",
[
httpx.LocalProtocolError("Illegal header value b'Bearer secret\\nkey'"),
UnicodeEncodeError("ascii", "Bearer secretkeyé", 16, 17, "ordinal not in range(128)"),
],
)
def test_unsendable_api_key_is_not_echoed(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
def reject_header(*args: Any, **kwargs: Any) -> httpx.Response:
raise error

monkeypatch.setattr("decodo.http.httpx.request", reject_header)

result = DecodoWebScraperTool().run(url="https://example.com")

assert isinstance(result, ToolFailure)
assert result.code == "invalid_request"
assert "secret" not in result.message


def test_target_error_status_becomes_tool_failure(fake_api: FakeApi) -> None:
fake_api.body = {"results": [{"content": "", "status_code": 404}]}

Expand Down
Loading