diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..29f3c3d8a 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,5 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; + +export * from "./taskmarket"; diff --git a/typescript/agentkit/src/action-providers/taskmarket/constants.ts b/typescript/agentkit/src/action-providers/taskmarket/constants.ts new file mode 100644 index 000000000..022ceccfe --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/constants.ts @@ -0,0 +1,7 @@ +/** + * TaskMarket API constants. + * Public REST API — browse/get are free; paid actions require X402 (not used here without auth). + */ +export const TASKMARKET_API_BASE = "https://api.taskmarket.dev"; +export const TASKMARKET_DOCS = "https://docs.taskmarket.dev/"; +export const TASKMARKET_SITE = "https://taskmarket.dev/"; 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..78b224084 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/index.ts @@ -0,0 +1,3 @@ +export * from "./taskmarketActionProvider"; +export * from "./schemas"; +export * from "./constants"; 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..d6ae907d9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +/** + * List open TaskMarket tasks. + */ +export const ListOpenTasksSchema = z + .object({ + limit: z + .number() + .int() + .min(1) + .max(50) + .nullable() + .describe("Max tasks to return (1-50). Defaults to 10."), + mode: z + .string() + .nullable() + .describe("Optional mode filter: bounty, claim, pitch, benchmark, auction"), + tags: z + .string() + .nullable() + .describe("Optional comma-separated tags filter"), + }) + .strict(); + +/** + * Fetch a single task by id. + */ +export const GetTaskSchema = z + .object({ + taskId: z + .string() + .describe("TaskMarket task id (0x… hex) or full task URL"), + }) + .strict(); + +/** + * Draft a delegation proposal — does NOT create or fund a task. + */ +export const PrepareDelegationSchema = z + .object({ + description: z.string().describe("Concrete deliverable description for the task"), + rewardUsdc: z + .number() + .positive() + .describe("Proposed escrow reward in USDC (human-readable, e.g. 5)"), + durationHours: z + .number() + .positive() + .describe("Proposed task duration in hours"), + mode: z + .string() + .nullable() + .describe("Task mode (default bounty)"), + spendingLimitUsdc: z + .number() + .positive() + .describe("Hard spending ceiling the user must approve before any create/fund call"), + userAuthorized: z + .boolean() + .describe( + "Must be true only after the human explicitly approved this draft. False = draft only.", + ), + }) + .strict(); + +/** + * Submit work to an existing task. Requires explicit user authorization. + * Does not create tasks or move funds. + */ +export const SubmitWorkSchema = z + .object({ + taskId: z.string().describe("Target task id (0x…)"), + deliverableSummary: z + .string() + .describe("Short summary of the deliverable being submitted"), + artifactPaths: z + .array(z.string()) + .nullable() + .describe("Optional local file paths / URLs for evidence artifacts"), + userAuthorized: z + .boolean() + .describe( + "REQUIRED true: human must have authorized this submission. Silent submit is forbidden.", + ), + }) + .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..82eb08970 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts @@ -0,0 +1,107 @@ +import { taskmarketActionProvider } from "./taskmarketActionProvider"; + +describe("TaskMarketActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + const provider = taskmarketActionProvider(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("listOpenTasks", () => { + it("returns mapped tasks on success", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + tasks: [ + { + id: "0xabc", + mode: "bounty", + reward: "1500000", + submissionCount: 2, + tags: ["ai"], + description: "Hello world task", + }, + ], + }), + }); + const result = await provider.listOpenTasks({ + limit: 5, + mode: null, + tags: null, + }); + const parsed = JSON.parse(result); + expect(parsed.count).toBe(1); + expect(parsed.tasks[0].rewardUsdcApprox).toBe(1.5); + }); + + it("handles API errors", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }); + const result = await provider.listOpenTasks({ + limit: null, + mode: null, + tags: null, + }); + expect(result).toContain("Error listing TaskMarket tasks"); + }); + }); + + describe("prepareDelegation", () => { + it("returns pending_approval when not authorized", async () => { + const result = await provider.prepareDelegation({ + description: "demo", + rewardUsdc: 1, + durationHours: 24, + spendingLimitUsdc: 5, + userAuthorized: false, + mode: null, + }); + expect(JSON.parse(result).status).toBe("pending_approval"); + }); + + it("rejects over spending limit", async () => { + const result = await provider.prepareDelegation({ + description: "demo", + rewardUsdc: 10, + durationHours: 24, + spendingLimitUsdc: 5, + userAuthorized: true, + mode: "bounty", + }); + expect(JSON.parse(result).status).toBe("rejected"); + }); + }); + + describe("submitWork", () => { + it("blocks when userAuthorized is false", async () => { + const result = await provider.submitWork({ + taskId: "0xabc", + deliverableSummary: "x", + artifactPaths: null, + userAuthorized: false, + }); + expect(JSON.parse(result).status).toBe("blocked"); + }); + + it("returns plan when authorized", async () => { + const result = await provider.submitWork({ + taskId: "https://taskmarket.dev/tasks/0xdeadbeef", + deliverableSummary: "evidence", + artifactPaths: ["a.md"], + userAuthorized: true, + }); + const parsed = JSON.parse(result); + expect(parsed.status).toBe("authorized_submission_plan"); + expect(parsed.taskId).toBe("0xdeadbeef"); + }); + }); + + describe("supportsNetwork", () => { + it("supports all networks", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }), + ).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..2493ba562 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts @@ -0,0 +1,292 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { + GetTaskSchema, + ListOpenTasksSchema, + PrepareDelegationSchema, + SubmitWorkSchema, +} from "./schemas"; +import { TASKMARKET_API_BASE, TASKMARKET_DOCS, TASKMARKET_SITE } from "./constants"; + +function normalizeTaskId(taskIdOrUrl: string): string { + const trimmed = taskIdOrUrl.trim(); + const fromUrl = trimmed.match(/tasks\/(0x[a-fA-F0-9]+)/); + if (fromUrl) { + return fromUrl[1]; + } + return trimmed; +} + +/** + * TaskMarketActionProvider lets agents discover and (with explicit user auth) + * prepare / submit work on TaskMarket — an on-chain USDC task marketplace. + * + * Security contract (bounty requirement): + * - Browse/get are read-only and free. + * - prepare_delegation never creates or funds a task by itself. + * - submit_work refuses unless userAuthorized === true. + * - This provider never requests private keys or bypasses wallet permissions. + */ +export class TaskMarketActionProvider extends ActionProvider { + /** + * Constructor for the TaskMarketActionProvider class. + */ + constructor() { + super("taskmarket", []); + } + + /** + * Lists open TaskMarket tasks. + * + * @param args - List filters + * @returns JSON string of open tasks (truncated fields) or error + */ + @CreateAction({ + name: "taskmarket_list_open_tasks", + description: `This tool lists open funded tasks on TaskMarket (https://taskmarket.dev). +It takes optional limit (1-50), mode, and tags. + +Important notes: +- Read-only and free — does not spend USDC or create tasks +- Use this when a user request is better delegated to external workers +- Returns task ids, rewards, modes, submission counts, and short descriptions +- See docs: ${TASKMARKET_DOCS}`, + schema: ListOpenTasksSchema, + }) + async listOpenTasks(args: z.infer): Promise { + try { + const limit = args.limit ?? 10; + const params = new URLSearchParams({ + status: "open", + limit: String(limit), + }); + if (args.mode) params.set("mode", args.mode); + if (args.tags) params.set("tags", args.tags); + + const url = `${TASKMARKET_API_BASE}/api/tasks?${params.toString()}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = (await response.json()) as { + tasks?: Array>; + }; + const tasks = (data.tasks ?? []).map(t => ({ + id: t.id, + mode: t.mode, + rewardBaseUnits: t.reward, + rewardUsdcApprox: + t.reward != null ? Number(t.reward) / 1e6 : null, + submissionCount: t.submissionCount, + tags: t.tags, + descriptionPreview: + typeof t.description === "string" + ? t.description.slice(0, 240).replace(/\s+/g, " ") + : null, + url: `${TASKMARKET_SITE}tasks/${t.id}`, + })); + return JSON.stringify( + { + count: tasks.length, + tasks, + note: "Read-only listing. Creating/funding tasks requires separate explicit user authorization.", + }, + null, + 2, + ); + } catch (error: unknown) { + return `Error listing TaskMarket tasks: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + + /** + * Fetches one TaskMarket task by id or URL. + * + * @param args - Task id or URL + * @returns JSON task detail or error + */ + @CreateAction({ + name: "taskmarket_get_task", + description: `This tool fetches a single TaskMarket task by id (0x…) or task URL. +Read-only and free. Use it to understand reward, deadline, mode, and deliverable requirements +before proposing delegation to the user.`, + schema: GetTaskSchema, + }) + async getTask(args: z.infer): Promise { + try { + const taskId = normalizeTaskId(args.taskId); + const url = `${TASKMARKET_API_BASE}/api/tasks/${taskId}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const t = (await response.json()) as Record; + return JSON.stringify( + { + id: t.id, + mode: t.mode, + status: t.status, + rewardBaseUnits: t.reward, + rewardUsdcApprox: t.reward != null ? Number(t.reward) / 1e6 : null, + submissionCount: t.submissionCount, + tags: t.tags, + expiryTime: t.expiryTime, + description: t.description, + url: `${TASKMARKET_SITE}tasks/${t.id}`, + authNote: + "Inspect only. Do not create, fund, accept, or submit without explicit userAuthorized=true.", + }, + null, + 2, + ); + } catch (error: unknown) { + return `Error fetching TaskMarket task: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + + /** + * Prepares a delegation draft. Never creates or funds a task. + * + * @param args - Draft fields + authorization flag + * @returns Draft JSON for human review, or blocked message + */ + @CreateAction({ + name: "taskmarket_prepare_delegation", + description: `This tool DRAFTS a TaskMarket task delegation proposal for human review. +It does NOT create a task, escrow USDC, or call paid APIs. + +Required inputs: +- description, rewardUsdc, durationHours, spendingLimitUsdc +- userAuthorized: set true ONLY after the human approved the draft parameters + +If userAuthorized is false, returns a draft marked pending_approval. +If true, returns an approved_draft the operator may execute out-of-band with their wallet CLI +(subject to spendingLimitUsdc). Never silently spend.`, + schema: PrepareDelegationSchema, + }) + async prepareDelegation( + args: z.infer, + ): Promise { + if (args.rewardUsdc > args.spendingLimitUsdc) { + return JSON.stringify({ + status: "rejected", + reason: `rewardUsdc (${args.rewardUsdc}) exceeds spendingLimitUsdc (${args.spendingLimitUsdc})`, + }); + } + + const draft = { + site: TASKMARKET_SITE, + docs: TASKMARKET_DOCS, + mode: args.mode ?? "bounty", + description: args.description, + rewardUsdc: args.rewardUsdc, + durationHours: args.durationHours, + spendingLimitUsdc: args.spendingLimitUsdc, + cliHint: + `taskmarket task create --description ${JSON.stringify(args.description)} ` + + `--reward ${args.rewardUsdc} --duration ${args.durationHours} --mode ${args.mode ?? "bounty"}`, + warnings: [ + "Creating a task escrows USDC from the requester wallet.", + "Only run the CLI after the human confirms and has funded their wallet intentionally.", + "This tool never holds private keys.", + ], + }; + + if (!args.userAuthorized) { + return JSON.stringify( + { + status: "pending_approval", + message: + "Draft prepared. Present this to the user. Re-call with userAuthorized=true only after they approve.", + draft, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + status: "approved_draft", + message: + "User authorized the draft. Operator may run the CLI hint within spendingLimitUsdc. Provider does not auto-execute.", + draft, + }, + null, + 2, + ); + } + + /** + * Submits work only when the human has authorized the action. + * This method returns a structured CLI instruction rather than embedding secrets. + * + * @param args - Submit request with userAuthorized gate + * @returns Submission plan or hard refusal + */ + @CreateAction({ + name: "taskmarket_submit_work", + description: `This tool prepares a TaskMarket work submission for an EXISTING task. +It refuses unless userAuthorized=true. + +It does not create tasks, accept work, or move funds. +Returns a safe submission plan / CLI hint the operator runs with their own TaskMarket worker identity.`, + schema: SubmitWorkSchema, + }) + async submitWork(args: z.infer): Promise { + if (!args.userAuthorized) { + return JSON.stringify({ + status: "blocked", + reason: + "userAuthorized=false. Refusing to submit. Ask the human to review the deliverable and re-call with userAuthorized=true.", + }); + } + + const taskId = normalizeTaskId(args.taskId); + const files = args.artifactPaths?.length + ? args.artifactPaths + : [""]; + + return JSON.stringify( + { + status: "authorized_submission_plan", + taskId, + deliverableSummary: args.deliverableSummary, + artifacts: files, + cliHints: files.map( + f => `taskmarket task submit ${taskId} --file ${JSON.stringify(f)}`, + ), + notes: [ + "Worker submission to most bounties is free (no USDC required).", + "Run the CLI from the operator machine that holds the TaskMarket keystore.", + "Never paste private keys into the agent context.", + ], + }, + null, + 2, + ); + } + + /** + * TaskMarket is network-agnostic at the action layer (Base settlement is handled by TaskMarket). + * + * @returns true for all networks + */ + supportsNetwork(_network: Network): boolean { + return true; + } +} + +/** + * Factory for TaskMarketActionProvider. + * + * @returns New provider instance + */ +export const taskmarketActionProvider = () => new TaskMarketActionProvider(); +