Skip to content
Open
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
18 changes: 18 additions & 0 deletions lib/crewai-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ CrewAI provides an extensive collection of powerful tools ready to enhance your

And many more robust tools to simplify your agent integrations.

### TaskMarket discovery (read-only)

`TaskMarketSearchTool` and `TaskMarketGetTaskTool` expose public TaskMarket
job discovery to a CrewAI agent. They use only `GET` requests: they never
claim tasks, submit artifacts, sign wallet messages, or move funds. Keep any
write-side workflow behind a separate, human-approved application boundary.

```python
from crewai import Agent
from crewai_tools import TaskMarketGetTaskTool, TaskMarketSearchTool

agent = Agent(
role="work scout",
goal="find small, legitimate jobs",
tools=[TaskMarketSearchTool(), TaskMarketGetTaskTool()],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

---

## Creating Custom Tools
Expand Down
4 changes: 4 additions & 0 deletions lib/crewai-tools/src/crewai_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
TavilyResearchTool,
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.taskmarket_tool import TaskMarketGetTaskTool, TaskMarketSearchTool
Comment thread
coderabbitai[bot] marked this conversation as resolved.
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
Expand Down Expand Up @@ -323,6 +324,8 @@
"SnowflakeSearchTool",
"SpiderTool",
"StagehandTool",
"TaskMarketGetTaskTool",
"TaskMarketSearchTool",
"TXTSearchTool",
"TavilyExtractorTool",
"TavilyGetResearchTool",
Expand All @@ -341,3 +344,4 @@
]

__version__ = "1.15.13"

4 changes: 4 additions & 0 deletions lib/crewai-tools/src/crewai_tools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
TavilyResearchTool,
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.taskmarket_tool import TaskMarketGetTaskTool, TaskMarketSearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
Expand Down Expand Up @@ -306,6 +307,8 @@
"SnowflakeSearchToolInput",
"SpiderTool",
"StagehandTool",
"TaskMarketGetTaskTool",
"TaskMarketSearchTool",
"TXTSearchTool",
"TavilyExtractorTool",
"TavilyGetResearchTool",
Expand All @@ -321,3 +324,4 @@
"YoutubeVideoSearchTool",
"ZapierActionTools",
]

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from crewai_tools.tools.taskmarket_tool.taskmarket_tool import (
TaskMarketGetTaskTool,
TaskMarketSearchTool,
)

__all__ = ["TaskMarketGetTaskTool", "TaskMarketSearchTool"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Read-only CrewAI tools for discovering work on TaskMarket.

These tools intentionally expose only public GET endpoints. They do not create
tasks, submit work, sign messages, access wallets, or move funds. A host
application must implement its own approval and wallet boundary for any
write-side TaskMarket action.
"""

from __future__ import annotations

import json
from decimal import Decimal, InvalidOperation
from typing import Any, ClassVar, Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen

from crewai.tools import BaseTool
from pydantic import BaseModel, Field


class TaskMarketSearchInput(BaseModel):
"""Arguments for a bounded, read-only TaskMarket search."""

max_reward_usdc: Decimal = Field(
Decimal("1"),
ge=Decimal("0"),
le=Decimal("1000000"),
description="Only return open tasks at or below this USDC reward ceiling.",
)
tags: str = Field(
"",
description="Optional comma-separated tags to pass to TaskMarket.",
)
limit: int = Field(
20,
ge=1,
le=100,
description="Maximum number of candidate tasks to inspect.",
)


class TaskMarketGetTaskInput(BaseModel):
"""Arguments for reading one public TaskMarket task."""

task_id: str = Field(..., min_length=1, description="Opaque TaskMarket task ID.")


class _TaskMarketReadOnlyTool(BaseTool):
"""Shared HTTP and reward parsing helpers for the public tools."""

base_url: str = "https://api.taskmarket.dev/api"
request_timeout: int = Field(default=20, ge=1, le=120)
DEFAULT_BASE_URL: ClassVar[str] = "https://api.taskmarket.dev/api"

@staticmethod
def _reward_usdc(row: Mapping[str, Any]) -> Decimal:
"""Normalize a TaskMarket reward into decimal USDC units."""
raw = row.get("rewardUsdc", row.get("reward", "0"))
try:
value = Decimal(str(raw))
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"TaskMarket returned an invalid reward: {raw!r}") from exc

# TaskMarket's canonical reward is an integer in six-decimal USDC base
# units. Cached/page payloads may provide rewardUsdc as a decimal.
if "rewardUsdc" not in row and (isinstance(raw, int) or "." not in str(raw)):
value /= Decimal("1000000")
return value

def _get_json(self, path: str) -> Any:
"""Fetch and decode one public TaskMarket JSON endpoint."""
request = Request(
f"{self.base_url.rstrip('/')}/{path.lstrip('/')}",
headers={"Accept": "application/json"},
method="GET",
)
try:
with urlopen(request, timeout=self.request_timeout) as response: # noqa: S310
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"TaskMarket HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"TaskMarket network error: {exc.reason}") from exc
except json.JSONDecodeError as exc:
raise RuntimeError("TaskMarket returned non-JSON data") from exc

@staticmethod
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""Return the stable, non-sensitive fields exposed to an agent."""
tags = row.get("tags") or []
if isinstance(tags, str):
tags = [tags]
return {
"id": str(row.get("id", "")),
"reward_usdc": str(_TaskMarketReadOnlyTool._reward_usdc(row)),
"status": str(row.get("status", "")),
"mode": str(row.get("mode", row.get("taskMode", ""))),
"description": str(row.get("description", "")),
"deadline": row.get("deadline") or row.get("expiryTime"),
"tags": [str(tag) for tag in tags],
}


class TaskMarketSearchTool(_TaskMarketReadOnlyTool):
"""Find open TaskMarket jobs without touching a wallet or signing anything."""

name: str = "taskmarket_search_open_work"
description: str = (
"Read-only discovery of public TaskMarket jobs. Returns open tasks at "
"or below a USDC reward ceiling; never submits work or moves funds."
)
args_schema: type[BaseModel] = TaskMarketSearchInput

def _run(
self,
max_reward_usdc: Decimal = Decimal("1"),
tags: str = "",
limit: int = 20,
) -> str:
"""Return qualifying open tasks without performing a write operation."""
args = TaskMarketSearchInput(
max_reward_usdc=max_reward_usdc,
tags=tags,
limit=limit,
)
params: dict[str, str] = {
"status": "open",
"sort": "reward_asc",
"limit": str(args.limit),
}
if args.tags.strip():
params["tags"] = ",".join(
tag.strip() for tag in args.tags.split(",") if tag.strip()
)
payload = self._get_json(f"tasks?{urlencode(params)}")
rows = payload.get("tasks", payload) if isinstance(payload, Mapping) else payload
if not isinstance(rows, list):
raise RuntimeError("TaskMarket returned an unexpected task-list shape")

results = []
for row in rows[: args.limit]:
if not isinstance(row, Mapping):
continue
if str(row.get("status", "")).lower() != "open":
continue
if self._reward_usdc(row) <= args.max_reward_usdc:
results.append(self._public_row(row))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return json.dumps(results, ensure_ascii=False)


class TaskMarketGetTaskTool(_TaskMarketReadOnlyTool):
"""Read one public TaskMarket task by its opaque ID."""

name: str = "taskmarket_get_task"
description: str = (
"Read one public TaskMarket task by opaque ID. This is read-only and "
"does not claim, submit, sign, or pay for anything."
)
args_schema: type[BaseModel] = TaskMarketGetTaskInput

def _run(self, task_id: str) -> str:
"""Return one public task after validating and encoding its opaque ID."""
if not task_id or task_id in {".", ".."} or "/" in task_id or "\\" in task_id:
raise ValueError("task_id must be a non-empty opaque ID")
payload = self._get_json(f"tasks/{quote(task_id, safe='')}")
if not isinstance(payload, Mapping):
raise RuntimeError("TaskMarket returned an unexpected task shape")
return json.dumps(self._public_row(payload), ensure_ascii=False)
110 changes: 110 additions & 0 deletions lib/crewai-tools/tests/tools/taskmarket_tool_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import json
from urllib.request import Request

from crewai_tools import TaskMarketGetTaskTool, TaskMarketSearchTool


class FakeResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")

def __enter__(self):
return self

def __exit__(self, *_args):
return False

def read(self):
return self._payload


def test_search_filters_open_tasks_by_usdc_ceiling(monkeypatch):
requests = []

def fake_urlopen(request: Request, timeout: int):
requests.append((request.full_url, timeout))
return FakeResponse(
{
"tasks": [
{"id": "cheap", "reward": "500000", "status": "open", "tags": ["python"]},
{"id": "expensive", "reward": "1000001", "status": "open"},
{"id": "closed", "reward": "1", "status": "closed"},
]
}
)

monkeypatch.setattr("crewai_tools.tools.taskmarket_tool.taskmarket_tool.urlopen", fake_urlopen)
result = json.loads(TaskMarketSearchTool()._run(max_reward_usdc="1", tags="python"))

assert [row["id"] for row in result] == ["cheap"]
assert result[0]["reward_usdc"] == "0.5"
assert "status=open" in requests[0][0]
assert "tags=python" in requests[0][0]


def test_search_respects_limit_before_processing_rows(monkeypatch):
def fake_urlopen(_request: Request, timeout: int):
assert timeout == 20
return FakeResponse(
{
"tasks": [
{"id": "first", "reward": "100000", "status": "open"},
{"id": "second", "reward": "200000", "status": "open"},
{"id": "third", "reward": "300000", "status": "open"},
]
}
)

monkeypatch.setattr("crewai_tools.tools.taskmarket_tool.taskmarket_tool.urlopen", fake_urlopen)
result = json.loads(TaskMarketSearchTool()._run(max_reward_usdc="1", limit=2))

assert [row["id"] for row in result] == ["first", "second"]


def test_get_task_is_read_only_and_normalizes_decimal_reward(monkeypatch):
requests = []

def fake_urlopen(request: Request, timeout: int):
requests.append((request.method, request.full_url))
return FakeResponse(
{
"id": "task-1",
"rewardUsdc": "0.25",
"status": "open",
"description": "A public task",
}
)

monkeypatch.setattr("crewai_tools.tools.taskmarket_tool.taskmarket_tool.urlopen", fake_urlopen)
result = json.loads(TaskMarketGetTaskTool()._run("task-1"))

assert result["reward_usdc"] == "0.25"
assert requests == [("GET", "https://api.taskmarket.dev/api/tasks/task-1")]


def test_get_task_rejects_path_traversal():
for task_id in ("../wallet", ".", ".."):
try:
TaskMarketGetTaskTool()._run(task_id)
except ValueError as exc:
assert "opaque ID" in str(exc)
else:
raise AssertionError(f"unsafe task ID should be rejected: {task_id!r}")


def test_get_task_encodes_query_fragment_and_encoded_separator(monkeypatch):
requests = []

def fake_urlopen(request: Request, timeout: int):
requests.append((request.method, request.full_url, timeout))
return FakeResponse({"id": "safe", "rewardUsdc": "0.1", "status": "open"})

monkeypatch.setattr("crewai_tools.tools.taskmarket_tool.taskmarket_tool.urlopen", fake_urlopen)
for task_id in ("task?query", "task#fragment", "task%2Fencoded"):
TaskMarketGetTaskTool()._run(task_id)

assert [url for _method, url, _timeout in requests] == [
"https://api.taskmarket.dev/api/tasks/task%3Fquery",
"https://api.taskmarket.dev/api/tasks/task%23fragment",
"https://api.taskmarket.dev/api/tasks/task%252Fencoded",
]