diff --git a/typescript/.changeset/taskmarket-action-provider.md b/typescript/.changeset/taskmarket-action-provider.md new file mode 100644 index 000000000..b79fde01e --- /dev/null +++ b/typescript/.changeset/taskmarket-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added a Taskmarket action provider integrating the on-chain Taskmarket bounty marketplace. It exposes `list_tasks`, `get_task`, and `my_submissions` for discovering and tracking escrowed bounty work, plus `submit_work` and `create_task` which are gated behind an explicit `confirm` input so no funds move without user authorization. Requires the `taskmarket` CLI to be installed and initialized on the host. diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..07dc749a3 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./taskmarket"; diff --git a/typescript/agentkit/src/action-providers/taskmarket/README.md b/typescript/agentkit/src/action-providers/taskmarket/README.md new file mode 100644 index 000000000..cf27ea368 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/README.md @@ -0,0 +1,56 @@ +# Taskmarket Action Provider + +This action provider integrates [Taskmarket](https://taskmarket.dev) — an on-chain bounty marketplace where agents complete digital work for escrowed USDC rewards — into AgentKit. + +It enables the full delegation loop inside any AgentKit-powered agent: + +1. **Discover work**: `list_tasks` browses open, escrow-backed bounties. +2. **Inspect before acting**: `get_task` returns the full task record (reward, expiry, escrow transaction, submission window). +3. **Track results**: `my_submissions` reports the wallet's submissions and their award state. +4. **Delegate with authorization**: `create_task` posts new work to the marketplace and `submit_work` delivers completed work — both are gated behind an explicit `confirm` input so funds and on-chain state are never touched without user approval. + +## Setup + +The provider delegates to the official `taskmarket` CLI, which must be installed and initialized on the host: + +```bash +npm install -g @lucid-agents/taskmarket@latest +taskmarket init +taskmarket address +``` + +`taskmarket init` creates and registers the worker wallet used by every command. The provider never reads or exports wallet keys — signing and payment stay inside the CLI's own keystore. + +## Usage with AgentKit + +```typescript +import { taskmarketActionProvider } from "@coinbase/agentkit"; + +const actions = [ + taskmarketActionProvider(), + // other action providers... +]; +``` + +Then prompt your agent, for example: + +``` +List open Taskmarket tasks with a reward of at least 2 USDC, then show me the +full details of the first one. Do not submit anything without asking me. +``` + +## Actions + +| Action | Description | Spend-gated | +| --- | --- | --- | +| `list_tasks` | Browse open tasks with status/limit/reward/tag filters | No | +| `get_task` | Fetch one task's full on-chain record | No | +| `my_submissions` | List the wallet's submissions across all tasks | No | +| `submit_work` | Submit a deliverable file to a task | `confirm` required | +| `create_task` | Create a task and escrow its USDC reward | `confirm` required | + +## Security notes + +- Read actions (`list_tasks`, `get_task`, `my_submissions`) never move funds. +- `submit_work` anchors the artifact on-chain and is irreversible; `create_task` escrows the reward amount. Both require the caller to set `confirm: true` explicitly. +- The provider adds no autonomous spending: every paid action originates from an explicit tool call with user-visible parameters. diff --git a/typescript/agentkit/src/action-providers/taskmarket/index.ts b/typescript/agentkit/src/action-providers/taskmarket/index.ts new file mode 100644 index 000000000..881021280 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/index.ts @@ -0,0 +1 @@ +export * from "./taskmarketActionProvider"; diff --git a/typescript/agentkit/src/action-providers/taskmarket/schemas.ts b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts new file mode 100644 index 000000000..767b68cae --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +/** + * Input schema for listing Taskmarket tasks + */ +export const ListTasksSchema = z + .object({ + status: z.string().describe("Task status filter, e.g. open").default("open"), + limit: z.number().int().min(1).max(100).describe("Maximum number of results").default(20), + rewardMin: z + .number() + .nullable() + .optional() + .describe("Optional minimum reward in USDC"), + tags: z.string().nullable().optional().describe("Optional comma-separated tag filter"), + }) + .strict(); + +/** + * Input schema for getting a single Taskmarket task + */ +export const GetTaskSchema = z + .object({ + taskId: z.string().describe("The 0x-prefixed Taskmarket task id"), + }) + .strict(); + +/** + * Input schema for listing the wallet's own submissions + */ +export const GetMySubmissionsSchema = z.object({}).strict(); + +/** + * Input schema for submitting work to a Taskmarket task + */ +export const SubmitWorkSchema = z + .object({ + taskId: z.string().describe("The 0x-prefixed Taskmarket task id"), + filePath: z.string().describe("Local path of the deliverable file to submit"), + role: z + .enum(["preview", "source", "final", "attachment"]) + .describe("Artifact role applied to the submitted file") + .default("final"), + confirm: z + .boolean() + .describe("Must be true to submit; submission is irreversible and anchors the artifact on-chain") + .default(false), + }) + .strict(); + +/** + * Input schema for creating a Taskmarket task + */ +export const CreateTaskSchema = z + .object({ + description: z.string().describe("Full task specification shown to workers"), + rewardUsdc: z.number().positive().describe("Reward in USDC; this amount is escrowed"), + durationHours: z.number().int().positive().describe("Hours until the task expires"), + tags: z.string().nullable().optional().describe("Optional comma-separated tags"), + confirm: z + .boolean() + .describe("Must be true to create the task and escrow the reward") + .default(false), + }) + .strict(); diff --git a/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts new file mode 100644 index 000000000..f4cf7f571 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts @@ -0,0 +1,201 @@ +import { execFile } from "child_process"; +import { taskmarketActionProvider } from "./taskmarketActionProvider"; + +jest.mock("child_process", () => ({ execFile: jest.fn() })); + +const execFileMock = execFile as unknown as jest.Mock; + +describe("TaskmarketActionProvider", () => { + const provider = taskmarketActionProvider(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const cliCall = (stdout: string, stderr = "") => + execFileMock.mockImplementation( + (cmd: string, args: string[], opts: unknown, cb: (e: Error | null, so: string, se: string) => void) => + cb(null, stdout, stderr), + ); + + const cliError = (message: string, stderr = "") => + execFileMock.mockImplementation( + (cmd: string, args: string[], opts: unknown, cb: (e: Error | null, so: string, se: string) => void) => + cb(new Error(message), "", stderr), + ); + + describe("listTasks", () => { + it("should invoke the CLI with default filters and return its output", async () => { + const output = JSON.stringify({ ok: true, data: { tasks: [] } }); + cliCall(output); + + const result = await provider.listTasks({ + status: "open", + limit: 20, + rewardMin: null, + tags: null, + }); + + expect(result).toEqual(output); + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + ["task", "list", "--status", "open", "--limit", "20"], + expect.anything(), + expect.any(Function), + ); + }); + + it("should pass through reward and tag filters", async () => { + cliCall("{}"); + + await provider.listTasks({ status: "open", limit: 5, rewardMin: 1, tags: "html" }); + + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + ["task", "list", "--status", "open", "--limit", "5", "--reward-min", "1", "--tags", "html"], + expect.anything(), + expect.any(Function), + ); + }); + + it("should surface CLI errors with stderr detail", async () => { + cliError("spawn failed", "CLI not installed"); + + const result = await provider.listTasks({ status: "open", limit: 20, rewardMin: null, tags: null }); + + expect(result).toContain("Error listing Taskmarket tasks"); + expect(result).toContain("CLI not installed"); + }); + }); + + describe("getTask", () => { + it("should fetch a task by id", async () => { + const output = JSON.stringify({ ok: true, data: { id: "0xabc" } }); + cliCall(output); + + const result = await provider.getTask({ taskId: "0xabc" }); + + expect(result).toEqual(output); + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + ["task", "get", "0xabc"], + expect.anything(), + expect.any(Function), + ); + }); + + it("should return an error message for unknown tasks", async () => { + cliError("not found", "Task not found"); + + const result = await provider.getTask({ taskId: "0xdead" }); + + expect(result).toContain("Error getting Taskmarket task"); + expect(result).toContain("Task not found"); + }); + }); + + describe("mySubmissions", () => { + it("should list the wallet's submissions", async () => { + const output = JSON.stringify({ ok: true, data: [] }); + cliCall(output); + + const result = await provider.mySubmissions(); + + expect(result).toEqual(output); + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + ["task", "my-submissions"], + expect.anything(), + expect.any(Function), + ); + }); + }); + + describe("submitWork", () => { + it("should refuse to submit without explicit confirmation", async () => { + const result = await provider.submitWork({ + taskId: "0xabc", + filePath: "./index.html", + role: "final", + confirm: false, + }); + + expect(result).toContain("not confirmed"); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it("should submit with the file and role when confirmed", async () => { + const output = JSON.stringify({ ok: true, data: { submissionId: "sub-1" } }); + cliCall(output); + + const result = await provider.submitWork({ + taskId: "0xabc", + filePath: "./index.html", + role: "final", + confirm: true, + }); + + expect(result).toEqual(output); + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + ["task", "submit", "0xabc", "--file", "./index.html", "--role", "final"], + expect.anything(), + expect.any(Function), + ); + }); + }); + + describe("createTask", () => { + it("should refuse to create a task without explicit confirmation", async () => { + const result = await provider.createTask({ + description: "Build a logo", + rewardUsdc: 5, + durationHours: 48, + tags: null, + confirm: false, + }); + + expect(result).toContain("not confirmed"); + expect(result).toContain("5 USDC"); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it("should escrow and create the task when confirmed", async () => { + const output = JSON.stringify({ ok: true, data: { id: "0xnew" } }); + cliCall(output); + + const result = await provider.createTask({ + description: "Build a logo", + rewardUsdc: 5, + durationHours: 48, + tags: "design", + confirm: true, + }); + + expect(result).toEqual(output); + expect(execFileMock).toHaveBeenCalledWith( + "taskmarket", + [ + "task", + "create", + "--description", + "Build a logo", + "--reward", + "5", + "--duration", + "48", + "--tags", + "design", + ], + expect.anything(), + expect.any(Function), + ); + }); + }); + + describe("supportsNetwork", () => { + it("should always return true", () => { + expect(provider.supportsNetwork()).toBe(true); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts new file mode 100644 index 000000000..c36ad16a9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts @@ -0,0 +1,246 @@ +import { execFile } from "child_process"; +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { + ListTasksSchema, + GetTaskSchema, + GetMySubmissionsSchema, + SubmitWorkSchema, + CreateTaskSchema, +} from "./schemas"; + +/** + * TaskmarketActionProvider is an action provider for Taskmarket + * (https://taskmarket.dev) — an on-chain bounty marketplace for AI agents. + * + * It enables an agent to discover open bounty work, inspect a precise task, + * and track its own submissions by delegating to the `taskmarket` CLI. + * State-changing actions (`submit_work`, `create_task`) are gated behind an + * explicit `confirm` input so funds and on-chain state are never touched + * without user authorization. + */ +export class TaskmarketActionProvider extends ActionProvider { + private readonly cliPath: string; + + private readonly timeoutMs: number; + + /** + * Constructor for the TaskmarketActionProvider class. + * + * @param cliPath - Path to the taskmarket CLI executable (default "taskmarket") + * @param timeoutMs - Timeout for CLI invocations in milliseconds + */ + constructor(cliPath = "taskmarket", timeoutMs = 60_000) { + super("taskmarket", []); + this.cliPath = cliPath; + this.timeoutMs = timeoutMs; + } + + /** + * Runs the taskmarket CLI with the given arguments and resolves with stdout. + * + * @param args - CLI arguments to pass through + * @returns A promise resolving to the CLI stdout + */ + private run(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(this.cliPath, args, { timeout: this.timeoutMs }, (error, stdout, stderr) => { + if (error) { + const detail = (stderr && stderr.trim()) || error.message; + reject(new Error(detail)); + return; + } + resolve(stdout); + }); + }); + } + + /** + * Lists currently open Taskmarket tasks. + * + * @param args - Optional filters: status, limit, minimum reward, and tags + * @returns A JSON string containing matching tasks or an error message + */ + @CreateAction({ + name: "list_tasks", + description: `This tool lists open tasks on Taskmarket, an on-chain bounty marketplace where agents earn USDC for completed digital work. + +It takes the following optional inputs: +- status: task status filter (default "open") +- limit: maximum number of results (default 20) +- rewardMin: minimum reward in USDC +- tags: comma-separated tag filter + +Important notes: +- Each result includes the task id (0x-prefixed hex), description, reward, expiry, and submission count +- Use get_task with the task id for full details before doing any work`, + schema: ListTasksSchema, + }) + async listTasks(args: z.infer): Promise { + try { + const cliArgs = ["task", "list", "--status", args.status, "--limit", String(args.limit)]; + if (args.rewardMin !== undefined && args.rewardMin !== null) { + cliArgs.push("--reward-min", String(args.rewardMin)); + } + if (args.tags) { + cliArgs.push("--tags", args.tags); + } + return await this.run(cliArgs); + } catch (error: unknown) { + return `Error listing Taskmarket tasks: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Gets the full details of a single Taskmarket task. + * + * @param args - The task id to inspect + * @returns A JSON string containing the task record or an error message + */ + @CreateAction({ + name: "get_task", + description: `This tool fetches the full details of one Taskmarket task by its 0x-prefixed hex id. + +It takes the following inputs: +- taskId: the 0x-prefixed task id from list_tasks + +Important notes: +- Returns description, reward, expiry, escrow transaction, submission count, awards, and lifecycle phase +- Check that submissionWindowOpen is true, stakeRequired is false, and the reward is escrowed before starting work`, + schema: GetTaskSchema, + }) + async getTask(args: z.infer): Promise { + try { + return await this.run(["task", "get", args.taskId]); + } catch (error: unknown) { + return `Error getting Taskmarket task: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Lists submissions made by the configured Taskmarket wallet. + * + * @returns A JSON string containing the wallet's submissions or an error message + */ + @CreateAction({ + name: "my_submissions", + description: `This tool lists all Taskmarket submissions made by the configured wallet, across all tasks. + +Important notes: +- Requires the taskmarket CLI to be authenticated (taskmarket init) +- Use it to track whether submissions were accepted, rejected, or awarded`, + schema: GetMySubmissionsSchema, + }) + async mySubmissions(): Promise { + try { + return await this.run(["task", "my-submissions"]); + } catch (error: unknown) { + return `Error listing Taskmarket submissions: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Submits a file as work for a Taskmarket task. Requires explicit confirmation. + * + * @param args - Task id, file path, artifact role, and confirmation flag + * @returns The CLI submission result or an error message + */ + @CreateAction({ + name: "submit_work", + description: `This tool submits a completed work file to a Taskmarket task. + +It takes the following inputs: +- taskId: the 0x-prefixed task id +- filePath: local path of the deliverable file +- role: artifact role (preview, source, final, attachment; default final) +- confirm: must be explicitly set to true by the user to submit + +Important notes: +- Submission anchors the artifact on-chain and cannot be undone; never call this without explicit user approval +- Re-check the task with get_task immediately before submitting to confirm the window is still open +- Returns the submission id when successful`, + schema: SubmitWorkSchema, + }) + async submitWork(args: z.infer): Promise { + if (!args.confirm) { + return "Submission not confirmed. Set confirm: true to submit work to this task. Submitting is irreversible and anchors the artifact on-chain."; + } + try { + return await this.run([ + "task", + "submit", + args.taskId, + "--file", + args.filePath, + "--role", + args.role, + ]); + } catch (error: unknown) { + return `Error submitting Taskmarket work: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Creates a new Taskmarket task, escrowing the reward in USDC. Requires explicit confirmation. + * + * @param args - Description, reward, duration, tags, and confirmation flag + * @returns The CLI creation result or an error message + */ + @CreateAction({ + name: "create_task", + description: `This tool creates a new Taskmarket task so external workers can complete it for a USDC reward. + +It takes the following inputs: +- description: full task specification shown to workers +- rewardUsdc: reward in USDC — this amount is escrowed from the wallet +- durationHours: hours until the task expires +- tags: optional comma-separated tags +- confirm: must be explicitly set to true by the user to create and escrow + +Important notes: +- Creating a task spends the reward amount in USDC; never call this without explicit user approval +- Only create tasks for work the user actually wants delegated`, + schema: CreateTaskSchema, + }) + async createTask(args: z.infer): Promise { + if (!args.confirm) { + return `Task creation not confirmed. Set confirm: true to escrow ${args.rewardUsdc} USDC and publish this task.`; + } + try { + const cliArgs = [ + "task", + "create", + "--description", + args.description, + "--reward", + String(args.rewardUsdc), + "--duration", + String(args.durationHours), + ]; + if (args.tags) { + cliArgs.push("--tags", args.tags); + } + return await this.run(cliArgs); + } catch (error: unknown) { + return `Error creating Taskmarket task: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Checks if the Taskmarket action provider supports the given network. + * Taskmarket settles on Base but discovery is network-agnostic, so this always returns true. + * + * @returns True, as Taskmarket actions are supported on all networks. + */ + supportsNetwork(): boolean { + return true; + } +} + +/** + * Creates a new instance of the TaskmarketActionProvider. + * + * @returns A new TaskmarketActionProvider instance + */ +export const taskmarketActionProvider = () => new TaskmarketActionProvider();