From 1262c528d4f30c564f4f6064d671b4f027903f68 Mon Sep 17 00:00:00 2001 From: MirzaAliAkbar Date: Wed, 12 Aug 2026 06:19:50 +0500 Subject: [PATCH] feat: add Taskmarket action provider for Python AgentKit - Add TaskmarketActionProvider with create_taskmarket_task, get_taskmarket_task, and list_taskmarket_submissions actions - Schemas with validation for reward, duration, and task parameters - Unit tests covering success and failure paths - Subprocess-based CLI integration (first-party tooling) Closes: Taskmarket integration PR --- .../action_providers/__init__.py | 6 + .../action_providers/taskmarket/README.md | 73 ++++++ .../action_providers/taskmarket/__init__.py | 16 ++ .../action_providers/taskmarket/schemas.py | 73 ++++++ .../taskmarket/taskmarket_action_provider.py | 218 ++++++++++++++++++ .../action_providers/taskmarket/__init__.py | 0 .../test_taskmarket_action_provider.py | 209 +++++++++++++++++ 7 files changed, 595 insertions(+) create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py create mode 100644 python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py create mode 100644 python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py create mode 100644 python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index 68573da62..18ced31cc 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -32,6 +32,10 @@ SuperfluidActionProvider, superfluid_action_provider, ) +from .taskmarket.taskmarket_action_provider import ( + TaskmarketActionProvider, + taskmarket_action_provider, +) from .twitter.twitter_action_provider import TwitterActionProvider, twitter_action_provider from .wallet.wallet_action_provider import WalletActionProvider, wallet_action_provider from .weth.weth_action_provider import WethActionProvider, weth_action_provider @@ -57,6 +61,7 @@ "PythActionProvider", "SshActionProvider", "SuperfluidActionProvider", + "TaskmarketActionProvider", "TwitterActionProvider", "WalletActionProvider", "WethActionProvider", @@ -78,6 +83,7 @@ "pyth_action_provider", "ssh_action_provider", "superfluid_action_provider", + "taskmarket_action_provider", "twitter_action_provider", "wallet_action_provider", "weth_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md new file mode 100644 index 000000000..a4442a548 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/README.md @@ -0,0 +1,73 @@ +# Taskmarket Action Provider + +This directory contains the **TaskmarketActionProvider** implementation, which provides actions for interacting with [Taskmarket](https://taskmarket.dev) bounties and tasks on Base Mainnet. + +## Directory Structure + +``` +taskmarket/ +├── taskmarket_action_provider.py # Main provider with Taskmarket functionality +├── schemas.py # Taskmarket action schemas +├── __init__.py # Main exports +└── README.md # This file + +# From python/coinbase-agentkit/ +tests/action_providers/taskmarket/ +├── conftest.py # Test configuration (if needed) +└── test_taskmarket_action_provider.py # Test file for Taskmarket provider +``` + +## Actions + +- `create_taskmarket_task`: Create a new Taskmarket bounty task + - Requires description, reward, and duration + - Optionally accepts deliverables summary, max spend cap, and tags + - Escrows reward in USDC on Base Mainnet + +- `get_taskmarket_task`: Retrieve the current status of a Taskmarket task + - Returns status, reward, expiry, submission count, and pending actions + +- `list_taskmarket_submissions`: Retrieve submissions for a Taskmarket task + - Returns submission IDs, worker addresses, file URLs, timestamps, and rejection status + - Never silently accepts or rejects work + +## Network Support + +The Taskmarket provider supports Base Mainnet (chain 8453). + +## Setup + +1. Install the Taskmarket CLI: + ```bash + npm install -g @lucid-agents/taskmarket + taskmarket init + ``` + +2. Fund the agent wallet with USDC on Base Mainnet for task creation. + +## Usage + +```python +from coinbase_agentkit import AgentKit +from coinbase_agentkit.action_providers import taskmarket_action_provider + +agent_kit = AgentKit( + wallet_provider=wallet_provider, + action_providers=[taskmarket_action_provider()], +) + +# Create a task +result = agent_kit.get_actions()[0].invoke({ + "description": "Build a Taskmarket integration PR", + "reward": "5", + "duration_hours": 48, + "deliverables": "Working PR with tests", + "max_spend": "10", +}) +``` + +## Notes + +- Task creation requires the Taskmarket CLI to be installed and initialized. +- The CLI must be run in an environment where the agent wallet is registered. +- Network and spending checks are enforced at the CLI level. diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py new file mode 100644 index 000000000..ea32ae0e8 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/__init__.py @@ -0,0 +1,16 @@ +"""Taskmarket action provider package.""" + +from .schemas import ( + CreateTaskmarketTaskSchema, + GetTaskmarketTaskSchema, + ListTaskmarketSubmissionsSchema, +) +from .taskmarket_action_provider import TaskmarketActionProvider, taskmarket_action_provider + +__all__ = [ + "CreateTaskmarketTaskSchema", + "GetTaskmarketTaskSchema", + "ListTaskmarketSubmissionsSchema", + "TaskmarketActionProvider", + "taskmarket_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py new file mode 100644 index 000000000..8f0d8d044 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/schemas.py @@ -0,0 +1,73 @@ +"""Schemas for Taskmarket action provider.""" + +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, Field, field_validator + + +class CreateTaskmarketTaskSchema(BaseModel): + """Input schema for creating a Taskmarket task.""" + + description: str = Field( + ..., + description="The full task description including deliverables and acceptance criteria", + ) + reward: str = Field( + ..., + description="Reward amount in USDC whole units (e.g. '5' for 5 USDC)", + ) + duration_hours: int = Field( + ..., + description="Task duration in hours from creation", + ) + deliverables: str = Field( + default="", + description="Summary of expected deliverables for the requester workflow", + ) + max_spend: str = Field( + default="0", + description="Maximum total spend cap in USDC whole units (e.g. '10' for 10 USDC)", + ) + tags: str = Field( + default="", + description="Comma-separated tags for the task", + ) + + @field_validator("reward") + @classmethod + def validate_reward(cls, v: str) -> str: + """Validate reward is a positive decimal.""" + try: + d = Decimal(v) + if d <= 0: + raise ValueError("Reward must be positive") + except Exception as e: + raise ValueError(f"Reward must be a positive number: {e}") + return v + + @field_validator("duration_hours") + @classmethod + def validate_duration(cls, v: int) -> int: + """Validate duration is positive.""" + if v <= 0: + raise ValueError("Duration must be positive") + return v + + +class GetTaskmarketTaskSchema(BaseModel): + """Input schema for getting a Taskmarket task.""" + + task_id: str = Field( + ..., + description="The Taskmarket task ID to retrieve", + ) + + +class ListTaskmarketSubmissionsSchema(BaseModel): + """Input schema for listing Taskmarket task submissions.""" + + task_id: str = Field( + ..., + description="The Taskmarket task ID to list submissions for", + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py new file mode 100644 index 000000000..ee7bf41b5 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/taskmarket/taskmarket_action_provider.py @@ -0,0 +1,218 @@ +"""Taskmarket action provider for Coinbase AgentKit.""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + +from coinbase_agentkit.network import Network +from coinbase_agentkit.wallet_providers.wallet_provider import WalletProvider +from coinbase_agentkit.action_providers.action_decorator import create_action +from coinbase_agentkit.action_providers.action_provider import ActionProvider +from .schemas import ( + CreateTaskmarketTaskSchema, + GetTaskmarketTaskSchema, + ListTaskmarketSubmissionsSchema, +) + + +class TaskmarketActionProvider(ActionProvider[WalletProvider]): + """Provides actions for interacting with Taskmarket bounties and tasks.""" + + def __init__(self) -> None: + super().__init__("taskmarket", []) + + def supports_network(self, network: Network) -> bool: + """Taskmarket operates on Base Mainnet.""" + return network.chain_id == "8453" + + @staticmethod + def _run_cli(args: list[str]) -> str: + """Run a taskmarket CLI command and return stdout.""" + result = subprocess.run( + ["taskmarket", *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return json.dumps( + { + "error": True, + "message": result.stderr.strip() or "CLI command failed", + "stdout": result.stdout.strip(), + } + ) + try: + data = json.loads(result.stdout) + return json.dumps(data) + except json.JSONDecodeError: + return json.dumps( + { + "error": True, + "message": "Invalid JSON response from taskmarket CLI", + "stdout": result.stdout.strip(), + } + ) + + @create_action( + name="create_taskmarket_task", + description=""" + This tool creates a new Taskmarket bounty task on Base Mainnet. + + It takes the following inputs: + - description: The full task description including deliverables and acceptance criteria + - reward: Reward in USDC whole units (e.g. '5' for 5 USDC) + - duration_hours: Task duration in hours from creation + - deliverables: Summary of expected deliverables + - max_spend: Maximum total spend cap in USDC whole units (default '0') + - tags: Comma-separated tags (optional) + + Important notes: + - The user must explicitly confirm the task details before this action is invoked + - Taskmarket escrows the reward amount in USDC on Base Mainnet + - Returns the created task ID and link for tracking + """, + schema=CreateTaskmarketTaskSchema, + ) + def create_taskmarket_task( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + """Create a new Taskmarket task.""" + try: + validated = CreateTaskmarketTaskSchema(**args) + cli_args = [ + "task", + "create", + "--description", + validated.description, + "--reward", + validated.reward, + "--duration", + str(validated.duration_hours), + "--mode", + "bounty", + "--task-visibility", + "public", + ] + if validated.deliverables: + # Append deliverables to description for context + cli_args.extend(["--description", validated.description + f"\n\nDeliverables: {validated.deliverables}"]) + if validated.max_spend and validated.max_spend != "0": + cli_args.extend(["--max-price", validated.max_spend]) + if validated.tags: + cli_args.extend(["--tags", validated.tags]) + + output = self._run_cli(cli_args) + data = json.loads(output) + if data.get("error"): + return output + + return json.dumps( + { + "success": True, + "task_id": data.get("data", {}).get("id"), + "message": "Task created successfully on Base Mainnet. Reward is escrowed.", + }, + indent=2, + ) + except Exception as e: + return json.dumps({"error": True, "message": f"Failed to create task: {e}"}) + + @create_action( + name="get_taskmarket_task", + description=""" + This tool retrieves the current status of a Taskmarket task. + + It takes the following inputs: + - task_id: The Taskmarket task ID to retrieve + + Returns task details including status, reward, expiry, submission count, and any pending actions. + """, + schema=GetTaskmarketTaskSchema, + ) + def get_taskmarket_task( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + """Get a Taskmarket task by ID.""" + try: + validated = GetTaskmarketTaskSchema(**args) + output = self._run_cli(["task", "get", validated.task_id]) + data = json.loads(output) + if data.get("error"): + return output + + task = data.get("data", {}) + return json.dumps( + { + "success": True, + "task_id": task.get("id"), + "status": task.get("status"), + "reward": task.get("reward"), + "net_reward": task.get("netReward"), + "expiry": task.get("expiryTime"), + "submission_count": task.get("submissionCount"), + "pending_actions": [ + a.get("action") for a in task.get("pendingActions", []) + ], + }, + indent=2, + ) + except Exception as e: + return json.dumps({"error": True, "message": f"Failed to get task: {e}"}) + + @create_action( + name="list_taskmarket_submissions", + description=""" + This tool retrieves submissions for a Taskmarket task for human review. + + It takes the following inputs: + - task_id: The Taskmarket task ID to list submissions for + + Returns submission IDs, worker addresses, file URLs, submission timestamps, and rejection status. + Never silently accepts or rejects work. + """, + schema=ListTaskmarketSubmissionsSchema, + ) + def list_taskmarket_submissions( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + """List submissions for a Taskmarket task.""" + try: + validated = ListTaskmarketSubmissionsSchema(**args) + output = self._run_cli(["task", "submissions", validated.task_id]) + data = json.loads(output) + if data.get("error"): + return output + + submissions = data.get("data", []) + simplified = [] + for sub in submissions: + simplified.append( + { + "submission_id": sub.get("id"), + "worker_address": sub.get("workerAddress"), + "submitted_at": sub.get("submittedAt"), + "rejected_at": sub.get("rejectedAt"), + "file_url": sub.get("fileUrl"), + "deliverable_hash": sub.get("deliverableHash"), + } + ) + + return json.dumps( + { + "success": True, + "task_id": validated.task_id, + "submissions": simplified, + "count": len(simplified), + }, + indent=2, + ) + except Exception as e: + return json.dumps({"error": True, "message": f"Failed to list submissions: {e}"}) + + +def taskmarket_action_provider() -> TaskmarketActionProvider: + """Create a new TaskmarketActionProvider instance.""" + return TaskmarketActionProvider() diff --git a/python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py b/python/coinbase-agentkit/tests/action_providers/taskmarket/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py b/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py new file mode 100644 index 000000000..4eb2ffdde --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/taskmarket/test_taskmarket_action_provider.py @@ -0,0 +1,209 @@ +"""Tests for Taskmarket action provider.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from pydantic_core import ValidationError + +from coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider import ( + TaskmarketActionProvider, +) +from coinbase_agentkit.action_providers.taskmarket.schemas import ( + CreateTaskmarketTaskSchema, + GetTaskmarketTaskSchema, + ListTaskmarketSubmissionsSchema, +) + +MOCK_NETWORK_ID = "base-mainnet" +MOCK_WALLET_ADDRESS = "0xDa0B9B9Da39fC816a40634A888069c6A982e8D3D" +MOCK_TASK_ID = "0xfb182f610d57a6c056a8cfd1c9b691a0869c1e0d67c041ac27ed9f42a9c732a1" +MOCK_TASK_STATUS_RESPONSE = json.dumps({ + "ok": True, + "data": { + "id": MOCK_TASK_ID, + "status": "open", + "reward": "398000", + "netReward": "368150", + "expiryTime": "2026-08-25T20:37:34.047Z", + "submissionCount": 4, + "pendingActions": [], + } +}) +MOCK_SUBMISSIONS_RESPONSE = json.dumps({ + "ok": True, + "data": [ + { + "id": "sub-1", + "workerAddress": MOCK_WALLET_ADDRESS, + "submittedAt": "2026-08-11T20:39:47.262Z", + "rejectedAt": None, + "fileUrl": "s3://taskmarket/submissions/deliverable.zip", + "deliverableHash": "0xabc123", + } + ] +}) +MOCK_CREATE_RESPONSE = json.dumps({ + "ok": True, + "data": { + "id": MOCK_TASK_ID, + "status": "open", + "reward": "1000000", + } +}) +MOCK_ERROR_RESPONSE = json.dumps({ + "error": True, + "message": "Insufficient balance", +}) + + +def make_mock_wallet() -> MagicMock: + """Create a mock wallet provider.""" + wallet = MagicMock() + wallet.get_address.return_value = MOCK_WALLET_ADDRESS + network = MagicMock() + network.chain_id = "8453" + wallet.get_network.return_value = network + return wallet + + +def test_supports_network() -> None: + """Test network support logic.""" + provider = TaskmarketActionProvider() + mock_network = MagicMock() + mock_network.chain_id = "8453" + assert provider.supports_network(mock_network) is True + + mock_network_wrong = MagicMock() + mock_network_wrong.chain_id = "1" + assert provider.supports_network(mock_network_wrong) is False + + +def test_create_taskmarket_task_schema_valid() -> None: + """Test valid create task schema.""" + schema = CreateTaskmarketTaskSchema( + description="Build a Taskmarket integration", + reward="5", + duration_hours=48, + deliverables="PR with tests", + max_spend="10", + tags="integration,test", + ) + assert schema.reward == "5" + assert schema.duration_hours == 48 + assert schema.max_spend == "10" + + +def test_create_taskmarket_task_schema_invalid_reward() -> None: + """Test invalid reward is rejected.""" + with pytest.raises(ValidationError): + CreateTaskmarketTaskSchema( + description="Build something", + reward="-5", + duration_hours=24, + ) + + +def test_create_taskmarket_task_schema_invalid_duration() -> None: + """Test invalid duration is rejected.""" + with pytest.raises(ValidationError): + CreateTaskmarketTaskSchema( + description="Build something", + reward="5", + duration_hours=-1, + ) + + +def test_get_taskmarket_task_success() -> None: + """Test successful task retrieval.""" + provider = TaskmarketActionProvider() + mock_wallet = make_mock_wallet() + + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run", + return_value=MagicMock(returncode=0, stdout=MOCK_TASK_STATUS_RESPONSE, stderr=""), + ): + response = provider.get_taskmarket_task(mock_wallet, {"task_id": MOCK_TASK_ID}) + data = json.loads(response) + assert data["success"] is True + assert data["task_id"] == MOCK_TASK_ID + assert data["status"] == "open" + assert data["submission_count"] == 4 + + +def test_list_taskmarket_submissions_success() -> None: + """Test successful submissions listing.""" + provider = TaskmarketActionProvider() + mock_wallet = make_mock_wallet() + + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run", + return_value=MagicMock(returncode=0, stdout=MOCK_SUBMISSIONS_RESPONSE, stderr=""), + ): + response = provider.list_taskmarket_submissions(mock_wallet, {"task_id": MOCK_TASK_ID}) + data = json.loads(response) + assert data["success"] is True + assert data["count"] == 1 + assert data["submissions"][0]["worker_address"] == MOCK_WALLET_ADDRESS + + +def test_create_taskmarket_task_success() -> None: + """Test successful task creation.""" + provider = TaskmarketActionProvider() + mock_wallet = make_mock_wallet() + + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run", + return_value=MagicMock(returncode=0, stdout=MOCK_CREATE_RESPONSE, stderr=""), + ): + response = provider.create_taskmarket_task( + mock_wallet, + { + "description": "Build a Taskmarket integration", + "reward": "5", + "duration_hours": 48, + "deliverables": "PR with tests", + "max_spend": "10", + "tags": "integration", + }, + ) + data = json.loads(response) + assert data["success"] is True + assert data["task_id"] == MOCK_TASK_ID + + +def test_create_taskmarket_task_cli_failure() -> None: + """Test task creation handles CLI failure.""" + provider = TaskmarketActionProvider() + mock_wallet = make_mock_wallet() + + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run", + return_value=MagicMock(returncode=1, stdout="", stderr="Insufficient balance"), + ): + response = provider.create_taskmarket_task( + mock_wallet, + { + "description": "Build something", + "reward": "5", + "duration_hours": 24, + }, + ) + data = json.loads(response) + assert data["error"] is True + assert "Insufficient balance" in data["message"] + + +def test_get_taskmarket_task_cli_failure() -> None: + """Test task retrieval handles CLI failure.""" + provider = TaskmarketActionProvider() + mock_wallet = make_mock_wallet() + + with patch( + "coinbase_agentkit.action_providers.taskmarket.taskmarket_action_provider.subprocess.run", + return_value=MagicMock(returncode=1, stdout="", stderr="Task not found"), + ): + response = provider.get_taskmarket_task(mock_wallet, {"task_id": "0xinvalid"}) + data = json.loads(response) + assert data["error"] is True + assert "Task not found" in data["message"]