From ab77a450210484729dfb0a7b555b24a103281a74 Mon Sep 17 00:00:00 2001 From: Agent59353 Date: Fri, 14 Aug 2026 07:14:53 +0800 Subject: [PATCH] feat: add Taskmarket action provider for delegated worker markets Lets AgentKit agents browse Taskmarket, preview a Base (8453) create-task spend, create only after explicit user authorization via the official CLI, and present submissions for human review. Creates never auto-retry when settlement is unknown and the provider never accepts or rejects work. --- typescript/agentkit/CHANGELOG.md | 6 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/taskmarket/DEMO.md | 44 ++ .../src/action-providers/taskmarket/README.md | 84 +++ .../src/action-providers/taskmarket/api.ts | 66 +++ .../src/action-providers/taskmarket/cli.ts | 85 +++ .../taskmarket/confirmation.ts | 109 ++++ .../src/action-providers/taskmarket/index.ts | 2 + .../action-providers/taskmarket/schemas.ts | 119 ++++ .../taskmarketActionProvider.test.ts | 270 +++++++++ .../taskmarket/taskmarketActionProvider.ts | 529 ++++++++++++++++++ 11 files changed, 1315 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/taskmarket/DEMO.md create mode 100644 typescript/agentkit/src/action-providers/taskmarket/README.md create mode 100644 typescript/agentkit/src/action-providers/taskmarket/api.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/cli.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/confirmation.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/index.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts diff --git a/typescript/agentkit/CHANGELOG.md b/typescript/agentkit/CHANGELOG.md index fa4b2af96..7fafd98c8 100644 --- a/typescript/agentkit/CHANGELOG.md +++ b/typescript/agentkit/CHANGELOG.md @@ -1,5 +1,11 @@ # AgentKit Changelog +## Unreleased + +### Patch Changes + +- Added `taskmarketActionProvider` so agents can browse Taskmarket work, preview a Base (8453) create-task spend, create only after explicit user authorization via the official CLI, and present submissions for human review. Creates never auto-retry when settlement is unknown and the provider never accepts or rejects work. + ## 0.11.0 ### Minor Changes diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..2a1e2f0fc 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -26,6 +26,7 @@ export * from "./opensea"; export * from "./spl"; export * from "./superfluid"; export * from "./sushi"; +export * from "./taskmarket"; export * from "./truemarkets"; export * from "./twitter"; export * from "./wallet"; diff --git a/typescript/agentkit/src/action-providers/taskmarket/DEMO.md b/typescript/agentkit/src/action-providers/taskmarket/DEMO.md new file mode 100644 index 000000000..99638dbe0 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/DEMO.md @@ -0,0 +1,44 @@ +# Taskmarket Action Provider — Demo Log + +Recorded 2026-08-13T23:15Z from the same public API the provider calls (`https://api.taskmarket.dev/api`). + +## 1. Browse open tasks (`list_taskmarket_tasks`) + +``` +GET https://api.taskmarket.dev/api/tasks?status=open&limit=3 +``` + +Returned 3 open bounties, including: + +| id prefix | mode | status | reward (base units) | +|---|---|---|---| +| `0xdf65bccc07b3681f` | bounty | open | 8000 (0.008 USDC) | +| `0xfb182f610d57a6c0` | bounty | open | 398000 (0.398 USDC) | +| `0xf41d2979b5765bda` | bounty | open | 100000000 (100 USDC) | + +No wallet, key, or spend involved. + +## 2. Live status (`get_taskmarket_task`) + +``` +GET https://api.taskmarket.dev/api/tasks/0xdf65bccc07b3681f4028a45bfb31e2ce49f311c1e549e7a80be6d21915b84e4c +``` + +Response included `id`, `status=open`, `reward`, `expiryTime`, `tags`, `mode`. Public URL: + +https://taskmarket.dev/tasks/0xdf65bccc07b3681f4028a45bfb31e2ce49f311c1e549e7a80be6d21915b84e4c + +## 3. Preview then create (authorization) + +`preview_taskmarket_task` returns the full spend preview (description, deliverables, reward, 7.5% platform fee, Base / chain 8453, max spend) plus a confirmation token. `create_taskmarket_task` is refused unless: + +- `iAuthorizeSpend === true` +- the token matches the exact payload +- `rewardUsdc <= maxSpendUsdc` +- a previous create is not sitting in unknown-settlement + +Covered by `taskmarketActionProvider.test.ts`. + +## 4. Submissions stay human-reviewed + +`list_taskmarket_submissions` returns `{ reviewOnly: true, autoAccept: false, autoReject: false }`. There is no accept/reject action on this provider. 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..9d7b200cc --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/README.md @@ -0,0 +1,84 @@ +# Taskmarket Action Provider + +This directory contains the **TaskmarketActionProvider**, which lets an AgentKit agent treat [Taskmarket](https://taskmarket.dev/) as a delegated worker market on **Base mainnet (chain 8453)**. + +## Directory Structure + +``` +taskmarket/ +├── taskmarketActionProvider.ts # Provider implementation +├── taskmarketActionProvider.test.ts # Unit tests +├── schemas.ts # Zod action schemas +├── confirmation.ts # Preview confirmation tokens +├── api.ts # Public Taskmarket REST client +├── cli.ts # Official Taskmarket CLI wrapper +├── index.ts # Package exports +└── README.md # This file +``` + +## Actions + +| Action | Spends? | Purpose | +|---|---|---| +| `list_taskmarket_tasks` | No | Browse open Taskmarket work | +| `get_taskmarket_task` | No | Live status, reward, deadline, URL | +| `preview_taskmarket_task` | No | Show description, reward, fee, Base network, max spend; issue confirmation token | +| `create_taskmarket_task` | Yes, via official CLI | Create/fund only after preview + `iAuthorizeSpend=true` | +| `list_taskmarket_submissions` | No | Present submissions for **human** review | + +There is **no** accept, reject, or auto-pay action. Review stays with the user. + +## Safety + +- Default `maxSpendUsdc` is `0`. Creates are blocked until the operator sets a limit. +- Create requires a confirmation token from `preview_taskmarket_task` for the **exact** payload. +- Create requires `iAuthorizeSpend: true` from a fresh user authorization. +- Reward must be `<= maxSpendUsdc`. +- If the official CLI times out, settlement is treated as unknown and the provider **refuses to retry**. +- The provider never asks for, stores, or logs private keys. Creates go through the first-party [`taskmarket`](https://docs.taskmarket.dev/reference/cli) CLI and the user's existing keystore. + +## Setup + +```bash +npm install -g @lucid-agents/taskmarket +taskmarket init +``` + +```ts +import { taskmarketActionProvider } from "@coinbase/agentkit"; + +const provider = taskmarketActionProvider({ + maxSpendUsdc: 5, // hard cap per create +}); +``` + +Optional env: + +- `TASKMARKET_MAX_SPEND_USDC` +- `TASKMARKET_API_BASE` (default `https://api.taskmarket.dev/api`) +- `TASKMARKET_CLI_PATH` (default `taskmarket`) + +## Reproduction + +```bash +# from typescript/agentkit +pnpm test -- taskmarketActionProvider.test.ts +``` + +Browse live tasks without a wallet: + +```ts +await provider.listTasks({ status: "open", limit: 5 }); +``` + +## Network Support + +Creates settle on Base mainnet only. `supportsNetwork` returns true for `base-mainnet` / chain `8453`. + +## Docs + +- https://taskmarket.dev/ +- https://docs.taskmarket.dev/ +- https://docs.taskmarket.dev/concepts/task-modes +- https://docs.taskmarket.dev/reference/cli +- https://api.taskmarket.dev/openapi.json diff --git a/typescript/agentkit/src/action-providers/taskmarket/api.ts b/typescript/agentkit/src/action-providers/taskmarket/api.ts new file mode 100644 index 000000000..7cc2a4b5a --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/api.ts @@ -0,0 +1,66 @@ +export const DEFAULT_TASKMARKET_API_BASE = "https://api.taskmarket.dev/api"; + +export interface TaskmarketApiClient { + getJson(path: string): Promise; +} + +export class FetchTaskmarketApiClient implements TaskmarketApiClient { + constructor(private readonly apiBase: string = DEFAULT_TASKMARKET_API_BASE) {} + + async getJson(path: string): Promise { + const url = `${this.apiBase.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "coinbase-agentkit-taskmarket/0.1", + }, + }); + + const text = await response.text(); + let parsed: unknown = text; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = { raw: text }; + } + + if (!response.ok) { + throw new Error(`Taskmarket API ${response.status} for ${path}: ${text.slice(0, 400)}`); + } + return parsed; + } +} + +export function toUsdc(rewardBaseUnits: string | number | undefined): number | null { + if (rewardBaseUnits === undefined || rewardBaseUnits === null) { + return null; + } + const asNumber = typeof rewardBaseUnits === "number" ? rewardBaseUnits : Number(rewardBaseUnits); + if (!Number.isFinite(asNumber)) { + return null; + } + return asNumber / 1_000_000; +} + +export function summarizeTask(task: Record): Record { + const reward = toUsdc(task.reward as string | number | undefined); + const netReward = toUsdc(task.netReward as string | number | undefined); + return { + id: task.id, + status: task.status, + phase: task.phase, + mode: task.mode, + rewardUsdc: reward, + netRewardUsdc: netReward, + submissionCount: task.submissionCount, + expiryTime: task.expiryTime, + createdAt: task.createdAt, + tags: task.tags, + network: "Base", + chainId: 8453, + url: task.id ? `https://taskmarket.dev/tasks/${task.id}` : undefined, + descriptionPreview: + typeof task.description === "string" ? task.description.slice(0, 280) : undefined, + }; +} diff --git a/typescript/agentkit/src/action-providers/taskmarket/cli.ts b/typescript/agentkit/src/action-providers/taskmarket/cli.ts new file mode 100644 index 000000000..e9fdf7823 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/cli.ts @@ -0,0 +1,85 @@ +import { spawn } from "child_process"; + +export interface CliResult { + exitCode: number | null; + timedOut: boolean; + stdout: string; + stderr: string; +} + +export interface TaskmarketCli { + run(args: string[]): Promise; +} + +export interface SpawnCliOptions { + command?: string; + timeoutMs?: number; +} + +/** + * Runs the first-party Taskmarket CLI. Callers must not retry when timedOut is true + * or when exitCode is null — settlement status is unknown. + */ +export class SpawnTaskmarketCli implements TaskmarketCli { + private readonly command: string; + private readonly timeoutMs: number; + + constructor(options: SpawnCliOptions = {}) { + this.command = options.command ?? process.env.TASKMARKET_CLI_PATH ?? "taskmarket"; + this.timeoutMs = options.timeoutMs ?? 60_000; + } + + run(args: string[]): Promise { + return new Promise(resolve => { + const child = spawn(this.command, args, { + shell: false, + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + child.kill(); + finish({ + exitCode: null, + timedOut: true, + stdout, + stderr: stderr + "\nCLI timed out; settlement status unknown. Do not retry.", + }); + }, this.timeoutMs); + + const finish = (result: CliResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + }; + + child.stdout.on("data", chunk => { + stdout += String(chunk); + }); + child.stderr.on("data", chunk => { + stderr += String(chunk); + }); + child.on("error", error => { + finish({ + exitCode: null, + timedOut: false, + stdout, + stderr: error.message, + }); + }); + child.on("close", code => { + finish({ + exitCode: code, + timedOut: false, + stdout, + stderr, + }); + }); + }); + } +} diff --git a/typescript/agentkit/src/action-providers/taskmarket/confirmation.ts b/typescript/agentkit/src/action-providers/taskmarket/confirmation.ts new file mode 100644 index 000000000..d94e3c719 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/confirmation.ts @@ -0,0 +1,109 @@ +import { createHash, timingSafeEqual } from "crypto"; + +export const TASKMARKET_NETWORK = { + name: "Base", + chainId: 8453, + networkId: "base-mainnet", +} as const; + +export const CONFIRMATION_TTL_MS = 15 * 60 * 1000; + +export interface TaskPreviewPayload { + description: string; + rewardUsdc: number; + durationHours: number; + mode: string; + tags: string; + deliverables: string; +} + +interface TokenBody extends TaskPreviewPayload { + issuedAt: number; +} + +/** + * Builds a confirmation token for a previewed create-task request. + * The token is bound to the exact payload and expires after CONFIRMATION_TTL_MS. + */ +export function issueConfirmationToken( + payload: TaskPreviewPayload, + issuedAt = Date.now(), +): string { + const body: TokenBody = { ...normalizePayload(payload), issuedAt }; + const encoded = Buffer.from(JSON.stringify(body), "utf8").toString("base64url"); + const digest = hashPayload(body); + return `${encoded}.${digest}`; +} + +/** + * Validates a confirmation token against the payload the caller is trying to create. + * Returns an error string when invalid; otherwise null. + */ +export function validateConfirmationToken( + token: string, + payload: TaskPreviewPayload, + now = Date.now(), +): string | null { + const parts = token.split("."); + if (parts.length !== 2) { + return "confirmationToken is malformed."; + } + + let body: TokenBody; + try { + body = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")) as TokenBody; + } catch { + return "confirmationToken could not be decoded."; + } + + const expected = hashPayload(body); + const actual = parts[1]; + if (!safeEqual(expected, actual)) { + return "confirmationToken signature is invalid."; + } + + if (typeof body.issuedAt !== "number" || now - body.issuedAt > CONFIRMATION_TTL_MS) { + return "confirmationToken has expired. Run preview_taskmarket_task again."; + } + if (body.issuedAt > now + 30_000) { + return "confirmationToken issuedAt is in the future."; + } + + const normalized = normalizePayload(payload); + if ( + body.description !== normalized.description || + body.rewardUsdc !== normalized.rewardUsdc || + body.durationHours !== normalized.durationHours || + body.mode !== normalized.mode || + body.tags !== normalized.tags || + body.deliverables !== normalized.deliverables + ) { + return "Create payload does not match the previewed confirmationToken. Preview again."; + } + + return null; +} + +export function normalizePayload(payload: TaskPreviewPayload): TaskPreviewPayload { + return { + description: payload.description.trim(), + rewardUsdc: Number(payload.rewardUsdc), + durationHours: Number(payload.durationHours), + mode: (payload.mode || "bounty").trim(), + tags: (payload.tags || "").trim(), + deliverables: payload.deliverables.trim(), + }; +} + +function hashPayload(body: TokenBody): string { + return createHash("sha256").update(JSON.stringify(body)).digest("hex"); +} + +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) { + return false; + } + return timingSafeEqual(left, right); +} 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..7086e3d5a --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +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..f26c2600e --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; + +/** + * Input schema for listing Taskmarket tasks. + */ +export const ListTaskmarketTasksSchema = z + .object({ + status: z + .enum([ + "open", + "claimed", + "worker_selected", + "pending_approval", + "review", + "appealing", + "disputed", + "completed", + "expired", + "cancelled", + "ALL", + ]) + .optional() + .describe("Filter by task status. Defaults to open."), + mode: z + .enum(["ALL", "bounty", "claim", "pitch", "benchmark", "auction"]) + .optional() + .describe("Filter by task mode."), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum number of tasks to return (1-50)."), + rewardMinUsdc: z + .number() + .nonnegative() + .optional() + .describe("Minimum reward in whole USDC units."), + tags: z.string().optional().describe("Optional comma-separated tags."), + }) + .describe("Input schema for listing Taskmarket tasks"); + +/** + * Input schema for fetching a single Taskmarket task. + */ +export const GetTaskmarketTaskSchema = z + .object({ + taskId: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/, "taskId must be a 0x-prefixed 32-byte hex id") + .describe("Taskmarket task id"), + }) + .describe("Input schema for fetching one Taskmarket task"); + +/** + * Input schema for previewing a Taskmarket task before any funds move. + */ +export const PreviewTaskmarketTaskSchema = z + .object({ + description: z + .string() + .min(20, "Description must be at least 20 characters.") + .max(10000) + .describe("Full task description, including deliverables."), + rewardUsdc: z + .number() + .positive("Reward must be greater than 0.") + .describe("Gross reward in whole USDC units that will be escrowed."), + durationHours: z + .number() + .positive("Duration must be greater than 0.") + .describe("How long the task stays open, in hours."), + mode: z + .enum(["bounty", "claim", "pitch", "benchmark", "auction"]) + .optional() + .describe("Task mode. Defaults to bounty."), + tags: z.string().optional().describe("Optional comma-separated tags."), + deliverables: z + .string() + .min(1) + .describe("Human-readable deliverable summary shown to the user before spend."), + }) + .describe("Input schema for previewing a Taskmarket create-task request"); + +/** + * Input schema for creating a Taskmarket task after explicit authorization. + */ +export const CreateTaskmarketTaskSchema = z + .object({ + description: z.string().min(20).max(10000).describe("Exact description from the preview."), + rewardUsdc: z.number().positive().describe("Exact reward from the preview, in whole USDC."), + durationHours: z.number().positive().describe("Exact duration from the preview, in hours."), + mode: z.enum(["bounty", "claim", "pitch", "benchmark", "auction"]).optional(), + tags: z.string().optional(), + deliverables: z.string().min(1), + confirmationToken: z + .string() + .min(16) + .describe("Token returned by preview_taskmarket_task for this exact payload."), + iAuthorizeSpend: z + .boolean() + .describe( + "Must be true. Fresh, explicit user authorization to escrow rewardUsdc USDC on Base.", + ), + }) + .describe("Input schema for creating a Taskmarket task after preview + user authorization"); + +/** + * Input schema for listing submissions for human review. + */ +export const ListTaskmarketSubmissionsSchema = z + .object({ + taskId: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/, "taskId must be a 0x-prefixed 32-byte hex id") + .describe("Taskmarket task id"), + }) + .describe("Input schema for listing Taskmarket submissions for human review"); 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..5fcf90fb8 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts @@ -0,0 +1,270 @@ +import { TaskmarketActionProvider } from "./taskmarketActionProvider"; +import { issueConfirmationToken } from "./confirmation"; +import { CliResult, TaskmarketCli } from "./cli"; +import { TaskmarketApiClient } from "./api"; + +const OPEN_TASK = { + id: "0x" + "ab".repeat(32), + status: "open", + phase: "active", + mode: "bounty", + reward: "4500000", + netReward: "4162500", + submissionCount: 2, + expiryTime: "2026-08-22T11:58:25.795Z", + createdAt: "2026-08-08T11:58:25.806Z", + tags: ["ai", "agents"], + description: "Submit one genuine integration of TaskMarket.", +}; + +function previewArgs() { + return { + description: "Write a one-page report on Base USDC settlement for Taskmarket agents.", + rewardUsdc: 1, + durationHours: 48, + mode: "bounty" as const, + tags: "research", + deliverables: "One markdown report with sources.", + }; +} + +describe("TaskmarketActionProvider", () => { + let apiCalls: string[]; + let cliCalls: string[][]; + let cliResult: CliResult; + let provider: TaskmarketActionProvider; + + beforeEach(() => { + apiCalls = []; + cliCalls = []; + cliResult = { exitCode: 0, timedOut: false, stdout: JSON.stringify({ data: { id: OPEN_TASK.id } }), stderr: "" }; + + const apiClient: TaskmarketApiClient = { + getJson: async (path: string) => { + apiCalls.push(path); + if (path.startsWith("/tasks?") || path === "/tasks") { + return { tasks: [OPEN_TASK] }; + } + if (path.endsWith("/submissions")) { + return [{ id: "sub_1", workerAddress: "0xabc", submittedAt: "2026-08-13T00:00:00.000Z" }]; + } + return OPEN_TASK; + }, + }; + + const cli: TaskmarketCli = { + run: async (args: string[]) => { + cliCalls.push(args); + return cliResult; + }, + }; + + provider = new TaskmarketActionProvider({ + maxSpendUsdc: 5, + apiClient, + cli, + }); + }); + + describe("list_taskmarket_tasks", () => { + it("returns summarized public tasks without spending", async () => { + const response = JSON.parse(await provider.listTasks({ status: "open", limit: 10 })); + + expect(response.success).toBe(true); + expect(response.network.chainId).toBe(8453); + expect(response.tasks[0].rewardUsdc).toBe(4.5); + expect(response.tasks[0].url).toContain(OPEN_TASK.id); + expect(apiCalls[0]).toContain("/tasks?"); + expect(cliCalls).toHaveLength(0); + }); + + it("surfaces API failures", async () => { + const failing = new TaskmarketActionProvider({ + maxSpendUsdc: 5, + apiClient: { + getJson: async () => { + throw new Error("boom"); + }, + }, + cli: { run: async () => cliResult }, + }); + + const response = JSON.parse(await failing.listTasks({})); + expect(response.error).toBe(true); + expect(response.details).toContain("boom"); + }); + }); + + describe("get_taskmarket_task", () => { + it("returns live status for a task id", async () => { + const response = JSON.parse(await provider.getTask({ taskId: OPEN_TASK.id })); + expect(response.success).toBe(true); + expect(response.status).toBe("open"); + expect(response.url).toBe(`https://taskmarket.dev/tasks/${OPEN_TASK.id}`); + }); + }); + + describe("preview_taskmarket_task", () => { + it("does not call the CLI and returns a confirmation token", async () => { + const response = JSON.parse(await provider.previewTask(previewArgs())); + expect(response.success).toBe(true); + expect(response.fundsMoved).toBe(false); + expect(response.preview.chainId).toBe(8453); + expect(response.preview.rewardUsdc).toBe(1); + expect(response.confirmationToken).toContain("."); + expect(cliCalls).toHaveLength(0); + }); + + it("blocks previews above the spend cap", async () => { + const response = JSON.parse( + await provider.previewTask({ ...previewArgs(), rewardUsdc: 99 }), + ); + expect(response.error).toBe(true); + expect(response.message).toContain("maxSpendUsdc"); + }); + + it("blocks creates when maxSpendUsdc is 0", async () => { + const locked = new TaskmarketActionProvider({ + maxSpendUsdc: 0, + apiClient: { getJson: async () => ({ tasks: [] }) }, + cli: { run: async () => cliResult }, + }); + const response = JSON.parse(await locked.previewTask(previewArgs())); + expect(response.error).toBe(true); + expect(response.message).toContain("maxSpendUsdc is 0"); + }); + }); + + describe("create_taskmarket_task", () => { + it("refuses to create without explicit authorization", async () => { + const preview = JSON.parse(await provider.previewTask(previewArgs())); + const response = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: false, + }), + ); + expect(response.error).toBe(true); + expect(response.message).toContain("authorization"); + expect(cliCalls).toHaveLength(0); + }); + + it("refuses a token that does not match the payload", async () => { + const other = issueConfirmationToken({ + description: "A completely different task description for mismatch.", + rewardUsdc: 1, + durationHours: 48, + mode: "bounty", + tags: "research", + deliverables: "One markdown report with sources.", + }); + const response = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: other, + iAuthorizeSpend: true, + }), + ); + expect(response.error).toBe(true); + expect(response.message).toContain("does not match"); + expect(cliCalls).toHaveLength(0); + }); + + it("creates through the official CLI after preview + authorization", async () => { + const preview = JSON.parse(await provider.previewTask(previewArgs())); + const response = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: true, + }), + ); + + expect(response.success).toBe(true); + expect(response.taskId).toBe(OPEN_TASK.id); + expect(response.url).toContain(OPEN_TASK.id); + expect(cliCalls[0]).toEqual([ + "task", + "create", + "--description", + previewArgs().description, + "--reward", + "1", + "--duration", + "48", + "--mode", + "bounty", + "--tags", + "research", + ]); + }); + + it("does not retry when settlement is unknown", async () => { + cliResult = { exitCode: null, timedOut: true, stdout: "", stderr: "timeout" }; + const preview = JSON.parse(await provider.previewTask(previewArgs())); + const first = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: true, + }), + ); + expect(first.settlementUnknown).toBe(true); + + cliResult = { exitCode: 0, timedOut: false, stdout: "{}", stderr: "" }; + const second = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: true, + }), + ); + expect(second.error).toBe(true); + expect(second.message).toContain("unknown settlement"); + expect(cliCalls).toHaveLength(1); + }); + + it("blocks a duplicate create of the same payload", async () => { + const preview = JSON.parse(await provider.previewTask(previewArgs())); + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: true, + }); + const again = JSON.parse( + await provider.createTask({ + ...previewArgs(), + confirmationToken: preview.confirmationToken, + iAuthorizeSpend: true, + }), + ); + expect(again.error).toBe(true); + expect(again.message).toContain("already submitted"); + expect(cliCalls).toHaveLength(1); + }); + }); + + describe("list_taskmarket_submissions", () => { + it("returns submissions for human review and never auto-accepts", async () => { + const response = JSON.parse(await provider.listSubmissions({ taskId: OPEN_TASK.id })); + expect(response.success).toBe(true); + expect(response.autoAccept).toBe(false); + expect(response.autoReject).toBe(false); + expect(response.reviewOnly).toBe(true); + expect(response.submissions).toHaveLength(1); + }); + }); + + describe("network support", () => { + it("supports Base mainnet and rejects other chains", () => { + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe( + true, + ); + expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "8453" })).toBe(true); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" })).toBe( + false, + ); + }); + }); +}); 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..31eee4925 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts @@ -0,0 +1,529 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { + CreateTaskmarketTaskSchema, + GetTaskmarketTaskSchema, + ListTaskmarketSubmissionsSchema, + ListTaskmarketTasksSchema, + PreviewTaskmarketTaskSchema, +} from "./schemas"; +import { + CONFIRMATION_TTL_MS, + TASKMARKET_NETWORK, + TaskPreviewPayload, + issueConfirmationToken, + normalizePayload, + validateConfirmationToken, +} from "./confirmation"; +import { + DEFAULT_TASKMARKET_API_BASE, + FetchTaskmarketApiClient, + TaskmarketApiClient, + summarizeTask, + toUsdc, +} from "./api"; +import { SpawnTaskmarketCli, TaskmarketCli } from "./cli"; + +/** + * Configuration for the Taskmarket action provider. + */ +export interface TaskmarketActionProviderConfig { + /** + * Maximum USDC the provider is allowed to escrow per create. Defaults to 0 + * (creates are blocked until the operator sets a limit). + */ + maxSpendUsdc?: number; + + /** + * Public Taskmarket API base. Defaults to https://api.taskmarket.dev/api + */ + apiBase?: string; + + /** + * Path to the first-party `taskmarket` CLI used only for authorized creates. + */ + cliPath?: string; + + /** + * Injected API client. Tests only. + */ + apiClient?: TaskmarketApiClient; + + /** + * Injected CLI runner. Tests only. + */ + cli?: TaskmarketCli; +} + +interface ResolvedConfig { + maxSpendUsdc: number; + apiBase: string; +} + +const PLATFORM_FEE_BPS = 750; + +/** + * TaskmarketActionProvider lets an AgentKit agent browse Taskmarket work, + * preview a requester create-flow, and (only after fresh explicit authorization) + * create a task through the official CLI. It never accepts or rejects submissions. + */ +export class TaskmarketActionProvider extends ActionProvider { + private readonly config: ResolvedConfig; + private readonly api: TaskmarketApiClient; + private readonly cli: TaskmarketCli; + private lastCreateFingerprint: string | null = null; + private lastCreateUnknownSettlement = false; + + /** + * Creates a new TaskmarketActionProvider. + * + * @param config - Spend limit, API base, and optional test doubles + */ + constructor(config: TaskmarketActionProviderConfig = {}) { + super("taskmarket", []); + + this.config = { + maxSpendUsdc: config.maxSpendUsdc ?? Number(process.env.TASKMARKET_MAX_SPEND_USDC ?? "0"), + apiBase: config.apiBase ?? process.env.TASKMARKET_API_BASE ?? DEFAULT_TASKMARKET_API_BASE, + }; + this.api = config.apiClient ?? new FetchTaskmarketApiClient(this.config.apiBase); + this.cli = config.cli ?? new SpawnTaskmarketCli({ command: config.cliPath }); + } + + /** + * Lists live Taskmarket tasks from the public API. Read-only. No spend. + * + * @param args - Optional filters + * @returns JSON string of summarized tasks + */ + @CreateAction({ + name: "list_taskmarket_tasks", + description: ` +Lists live Taskmarket bounties and other paid work. Read-only. Does not spend funds +and does not require a wallet. + +Use this when a user request is better delegated to an external worker market than +solved by more inference. Always show reward, deadline, mode, and the Taskmarket URL. + +Taskmarket runs on Base mainnet (chain 8453).`, + schema: ListTaskmarketTasksSchema, + }) + async listTasks(args: z.infer): Promise { + try { + const params = new URLSearchParams(); + params.set("status", args.status ?? "open"); + params.set("mode", args.mode ?? "ALL"); + params.set("limit", String(args.limit ?? 10)); + if (args.rewardMinUsdc !== undefined) { + params.set("minReward", String(Math.round(args.rewardMinUsdc * 1_000_000))); + } + if (args.tags) { + params.set("tags", args.tags); + } + + const payload = (await this.api.getJson(`/tasks?${params.toString()}`)) as { + tasks?: Record[]; + data?: { tasks?: Record[] }; + }; + const tasks = payload.tasks ?? payload.data?.tasks ?? []; + return JSON.stringify( + { + success: true, + network: TASKMARKET_NETWORK, + count: tasks.length, + tasks: tasks.map(summarizeTask), + note: "Browse only. Creating a task requires preview_taskmarket_task then an explicit authorized create.", + }, + null, + 2, + ); + } catch (error) { + return this.fail("Failed to list Taskmarket tasks", error); + } + } + + /** + * Fetches one Taskmarket task and its live status. Read-only. + * + * @param args - Task id + * @returns JSON string with task details and live status + */ + @CreateAction({ + name: "get_taskmarket_task", + description: ` +Fetches one Taskmarket task by id and returns live status, reward, deadline, +submission count, and the public URL. Read-only. Does not spend funds. +Never accept or reject work from this action.`, + schema: GetTaskmarketTaskSchema, + }) + async getTask(args: z.infer): Promise { + try { + const payload = (await this.api.getJson(`/tasks/${args.taskId}`)) as + | Record + | { data?: Record }; + const task = ((payload as { data?: Record }).data ?? + payload) as Record; + return JSON.stringify( + { + success: true, + network: TASKMARKET_NETWORK, + task: summarizeTask(task), + status: task.status, + phase: task.phase, + submissionCount: task.submissionCount, + awardCount: task.awardCount, + primaryAward: task.primaryAward ?? null, + url: `https://taskmarket.dev/tasks/${args.taskId}`, + }, + null, + 2, + ); + } catch (error) { + return this.fail("Failed to fetch Taskmarket task", error); + } + } + + /** + * Previews a create-task request. Shows every spend field. Does not create or fund. + * + * @param args - Proposed task + * @returns Preview plus a confirmation token + */ + @CreateAction({ + name: "preview_taskmarket_task", + description: ` +Prepares a Taskmarket requester create-flow WITHOUT spending any funds. + +You MUST call this before create_taskmarket_task. Show the user every field: +description, deliverables, gross reward, estimated platform fee, max spend cap, +duration, mode, network (Base, chain 8453), and the confirmation token. + +Do not create the task until the user explicitly authorizes the spend.`, + schema: PreviewTaskmarketTaskSchema, + }) + async previewTask(args: z.infer): Promise { + const payload = normalizePayload({ + description: args.description, + rewardUsdc: args.rewardUsdc, + durationHours: args.durationHours, + mode: args.mode ?? "bounty", + tags: args.tags ?? "", + deliverables: args.deliverables, + }); + + const spendCheck = this.checkSpend(payload.rewardUsdc); + if (spendCheck) { + return spendCheck; + } + + const feeUsdc = Number(((payload.rewardUsdc * PLATFORM_FEE_BPS) / 10_000).toFixed(6)); + const netToWorkers = Number((payload.rewardUsdc - feeUsdc).toFixed(6)); + const token = issueConfirmationToken(payload); + + return JSON.stringify( + { + success: true, + action: "preview_only", + fundsMoved: false, + network: TASKMARKET_NETWORK, + confirmationToken: token, + confirmationExpiresInMinutes: CONFIRMATION_TTL_MS / 60_000, + preview: { + description: payload.description, + deliverables: payload.deliverables, + rewardUsdc: payload.rewardUsdc, + estimatedPlatformFeeUsdc: feeUsdc, + estimatedNetToWorkersUsdc: netToWorkers, + durationHours: payload.durationHours, + mode: payload.mode, + tags: payload.tags, + maxSpendUsdc: this.config.maxSpendUsdc, + network: "Base", + chainId: 8453, + }, + nextSteps: [ + "Show this preview to the user in full.", + "Do not create the task unless the user explicitly authorizes the spend.", + "If authorized, call create_taskmarket_task with the SAME fields, this confirmationToken, and iAuthorizeSpend=true.", + ], + }, + null, + 2, + ); + } + + /** + * Creates a Taskmarket task through the official CLI after preview + explicit authorization. + * + * @param args - Exact preview payload plus confirmation + * @returns Created task id/link or a hard stop if settlement is unknown + */ + @CreateAction({ + name: "create_taskmarket_task", + description: ` +Creates and funds a Taskmarket task on Base using the official Taskmarket CLI. + +HARD RULES: +- Call preview_taskmarket_task first and reuse its confirmationToken. +- iAuthorizeSpend must be true from a fresh, explicit user authorization. Never invent it. +- Reward must be <= the configured maxSpendUsdc. +- Network is Base mainnet only (chain 8453). +- If the CLI times out or settlement is unknown, DO NOT retry. Report the unknown state. +- This action never accepts or rejects worker submissions.`, + schema: CreateTaskmarketTaskSchema, + }) + async createTask(args: z.infer): Promise { + if (this.lastCreateUnknownSettlement) { + return JSON.stringify( + { + error: true, + message: "Refusing to create: a previous create has unknown settlement status.", + details: + "The last CLI invocation timed out or returned no exit code. Retrying could double-spend. Inspect the Taskmarket CLI / wallet activity before doing anything else.", + }, + null, + 2, + ); + } + + if (args.iAuthorizeSpend !== true) { + return JSON.stringify( + { + error: true, + message: "Create blocked: missing explicit spend authorization.", + details: + "iAuthorizeSpend must be true from a fresh user confirmation. Preview the task and ask the user first.", + }, + null, + 2, + ); + } + + const payload: TaskPreviewPayload = normalizePayload({ + description: args.description, + rewardUsdc: args.rewardUsdc, + durationHours: args.durationHours, + mode: args.mode ?? "bounty", + tags: args.tags ?? "", + deliverables: args.deliverables, + }); + + const tokenError = validateConfirmationToken(args.confirmationToken, payload); + if (tokenError) { + return JSON.stringify({ error: true, message: tokenError }, null, 2); + } + + const spendCheck = this.checkSpend(payload.rewardUsdc); + if (spendCheck) { + return spendCheck; + } + + const fingerprint = JSON.stringify(payload); + if (this.lastCreateFingerprint === fingerprint) { + return JSON.stringify( + { + error: true, + message: "Create blocked: this exact payload was already submitted in this session.", + details: "Ask the user before creating a different task. Do not blindly retry.", + }, + null, + 2, + ); + } + + const cliArgs = [ + "task", + "create", + "--description", + payload.description, + "--reward", + String(payload.rewardUsdc), + "--duration", + String(payload.durationHours), + "--mode", + payload.mode, + ]; + if (payload.tags) { + cliArgs.push("--tags", payload.tags); + } + + const result = await this.cli.run(cliArgs); + + if (result.timedOut || result.exitCode === null) { + this.lastCreateUnknownSettlement = true; + return JSON.stringify( + { + error: true, + settlementUnknown: true, + message: "Create invoked, but settlement status is unknown.", + details: + "The official CLI timed out or did not return an exit code. Do not retry. Check wallet activity and `taskmarket inbox` before taking any other spend action.", + stderr: result.stderr.slice(0, 1000), + stdout: result.stdout.slice(0, 1000), + }, + null, + 2, + ); + } + + if (result.exitCode !== 0) { + return JSON.stringify( + { + error: true, + settlementUnknown: false, + message: "Taskmarket CLI refused to create the task.", + exitCode: result.exitCode, + stderr: result.stderr.slice(0, 1500), + stdout: result.stdout.slice(0, 1500), + }, + null, + 2, + ); + } + + this.lastCreateFingerprint = fingerprint; + const created = this.extractCreatedTask(result.stdout); + return JSON.stringify( + { + success: true, + fundsMoved: true, + network: TASKMARKET_NETWORK, + taskId: created.taskId, + url: created.taskId ? `https://taskmarket.dev/tasks/${created.taskId}` : undefined, + raw: result.stdout.slice(0, 2000), + nextSteps: [ + "Return the task id and URL to the user.", + "Use get_taskmarket_task to retrieve live status.", + "Use list_taskmarket_submissions to present work for HUMAN review.", + "Never accept or reject a submission automatically.", + ], + }, + null, + 2, + ); + } + + /** + * Lists submissions for a task so a human can review them. Never accepts or rejects. + * + * @param args - Task id + * @returns JSON string of submissions for review + */ + @CreateAction({ + name: "list_taskmarket_submissions", + description: ` +Lists worker submissions for a Taskmarket task so a HUMAN can review them. + +This action never accepts, rejects, rates, or pays a submission. Present the +results to the user and stop. Any accept/reject must be done by the user in +the official Taskmarket CLI or UI.`, + schema: ListTaskmarketSubmissionsSchema, + }) + async listSubmissions(args: z.infer): Promise { + try { + const payload = await this.api.getJson(`/tasks/${args.taskId}/submissions`); + const submissions = Array.isArray(payload) + ? payload + : ((payload as { data?: unknown }).data ?? payload); + + return JSON.stringify( + { + success: true, + taskId: args.taskId, + url: `https://taskmarket.dev/tasks/${args.taskId}`, + reviewOnly: true, + autoAccept: false, + autoReject: false, + submissions, + instruction: + "Present these submissions to the user. Do not accept or reject them. The user must review in the official Taskmarket UI or CLI.", + }, + null, + 2, + ); + } catch (error) { + return this.fail("Failed to list Taskmarket submissions", error); + } + } + + /** + * Taskmarket requester flows settle on Base mainnet only. + * + * @param network - AgentKit network + * @returns True for Base mainnet or when network is unset + */ + supportsNetwork(network: Network): boolean { + if (!network.networkId && !network.chainId) { + return true; + } + if (network.networkId === TASKMARKET_NETWORK.networkId) { + return true; + } + return String(network.chainId) === String(TASKMARKET_NETWORK.chainId); + } + + private checkSpend(rewardUsdc: number): string | null { + if (!Number.isFinite(this.config.maxSpendUsdc) || this.config.maxSpendUsdc <= 0) { + return JSON.stringify( + { + error: true, + message: "Create blocked: maxSpendUsdc is 0.", + details: + "Set TaskmarketActionProvider({ maxSpendUsdc }) or TASKMARKET_MAX_SPEND_USDC before any create. This is a spending limit, not a default reward.", + maxSpendUsdc: this.config.maxSpendUsdc, + }, + null, + 2, + ); + } + if (rewardUsdc > this.config.maxSpendUsdc) { + return JSON.stringify( + { + error: true, + message: "Create blocked: reward exceeds maxSpendUsdc.", + rewardUsdc, + maxSpendUsdc: this.config.maxSpendUsdc, + network: TASKMARKET_NETWORK, + }, + null, + 2, + ); + } + return null; + } + + private extractCreatedTask(stdout: string): { taskId?: string } { + const hex = stdout.match(/0x[0-9a-fA-F]{64}/); + if (hex) { + return { taskId: hex[0] }; + } + try { + const parsed = JSON.parse(stdout) as { + data?: { id?: string; taskId?: string }; + id?: string; + taskId?: string; + }; + return { taskId: parsed.data?.id ?? parsed.data?.taskId ?? parsed.id ?? parsed.taskId }; + } catch { + return {}; + } + } + + private fail(message: string, error: unknown): string { + const details = error instanceof Error ? error.message : String(error); + return JSON.stringify({ error: true, message, details }, null, 2); + } +} + +/** + * Factory for TaskmarketActionProvider. + * + * @param config - Spend limit and optional test doubles + * @returns Provider instance + */ +export const taskmarketActionProvider = (config: TaskmarketActionProviderConfig = {}) => + new TaskmarketActionProvider(config); + +export { toUsdc };