diff --git a/typescript/.changeset/bright-tasks-earn.md b/typescript/.changeset/bright-tasks-earn.md new file mode 100644 index 000000000..538b5efdf --- /dev/null +++ b/typescript/.changeset/bright-tasks-earn.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a guarded Taskmarket action provider for task discovery, economics analysis, and explicitly authorized submissions. 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/README.md b/typescript/agentkit/src/action-providers/taskmarket/README.md new file mode 100644 index 000000000..e3204ae56 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/README.md @@ -0,0 +1,21 @@ +# Taskmarket Action Provider + +Adds Taskmarket task discovery, task inspection, expected-value analysis, and guarded artifact submission to AgentKit. + +```ts +import { taskmarketActionProvider } from "@coinbase/agentkit"; + +const provider = taskmarketActionProvider(); // read-only by default +``` + +To enable submission, the host injects its own authenticated implementation. AgentKit does not receive a private key: + +```ts +const provider = taskmarketActionProvider({ + allowSubmissions: true, + submitWork: async ({ taskId, files }) => taskmarketCliSubmit(taskId, files), +}); +``` + +The `submit_work` action still requires the user-supplied phrase `SUBMIT TASKMARKET WORK`, re-fetches task state, and only proceeds when Taskmarket advertises a free worker submission action. The provider cannot fund tasks or perform paid actions. + 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..32d030ea0 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/index.ts @@ -0,0 +1,3 @@ +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..9398adc99 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const TaskmarketModeSchema = z.enum(["bounty", "claim", "pitch", "benchmark", "auction"]); + +export const ListTaskmarketTasksSchema = z.object({ + mode: TaskmarketModeSchema.nullable().describe("Optional Taskmarket task mode"), + tags: z.array(z.string()).nullable().describe("Optional skill tags"), + minRewardUsdc: z.number().nonnegative().nullable().describe("Minimum gross reward in USDC"), + deadlineHours: z.number().positive().nullable().describe("Only tasks expiring within this many hours"), + limit: z.number().int().min(1).max(100).nullable().transform(value => value ?? 20), +}); + +export const GetTaskmarketTaskSchema = z.object({ + taskId: z.string().regex(/^0x[0-9a-fA-F]{64}$/, "Expected a 32-byte hex task ID"), +}); + +export const AnalyzeTaskmarketTaskSchema = GetTaskmarketTaskSchema.extend({ + estimatedHours: z.number().positive().finite(), + probabilityOfWinning: z.number().min(0).max(1).nullable(), +}); + +export const SubmitTaskmarketWorkSchema = GetTaskmarketTaskSchema.extend({ + files: z.array(z.string().min(1)).min(1), + confirmation: z.string().describe('Must exactly equal "SUBMIT TASKMARKET WORK"'), +}); + +export interface TaskmarketSubmissionRequest { + taskId: string; + files: string[]; +} + +export interface TaskmarketConfig { + apiUrl?: string; + allowSubmissions?: boolean; + submitWork?: (request: TaskmarketSubmissionRequest) => Promise; +} + 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..c174fec25 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts @@ -0,0 +1,65 @@ +import { taskmarketActionProvider } from "./taskmarketActionProvider"; + +const TASK_ID = `0x${"a".repeat(64)}`; +const task = { + id: TASK_ID, + reward: "4000000", + netReward: "3700000", + platformFeeBps: 750, + submissionCount: 3, + status: "open", + phase: "active", + submissionWindowOpen: true, + pendingActions: [{ action: "submit", actor: "worker", requiresPayment: false }], +}; + +describe("TaskmarketActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + beforeEach(() => jest.resetAllMocks()); + + it("lists tasks with USDC converted to base units", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ tasks: [task] }) }); + await taskmarketActionProvider().listTasks({ mode: "bounty", tags: ["typescript"], minRewardUsdc: 2.5, deadlineHours: 24, limit: 10 }); + expect(fetchMock.mock.calls[0][0]).toContain("minReward=2500000"); + expect(fetchMock.mock.calls[0][0]).toContain("tags=typescript"); + }); + + it("calculates competition-adjusted expected value", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => task }); + const result = JSON.parse(await taskmarketActionProvider().analyzeTaskEconomics({ taskId: TASK_ID, estimatedHours: 2, probabilityOfWinning: null })); + expect(result.netRewardUsdc).toBe(3.7); + expect(result.probabilityOfWinning).toBe(0.25); + expect(result.expectedHourlyUsdc).toBe(0.4625); + }); + + it("blocks submissions by default", async () => { + const result = JSON.parse(await taskmarketActionProvider().submit({ taskId: TASK_ID, files: ["result.md"], confirmation: "SUBMIT TASKMARKET WORK" })); + expect(result.error).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("requires exact confirmation before submission", async () => { + const submitWork = jest.fn(); + const provider = taskmarketActionProvider({ allowSubmissions: true, submitWork }); + const result = JSON.parse(await provider.submit({ taskId: TASK_ID, files: ["result.md"], confirmation: "yes" })); + expect(result.error).toBe(true); + expect(submitWork).not.toHaveBeenCalled(); + }); + + it("revalidates free submission eligibility before delegating", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => task }); + const submitWork = jest.fn().mockResolvedValue({ submissionId: "sub_1" }); + const provider = taskmarketActionProvider({ allowSubmissions: true, submitWork }); + const result = JSON.parse(await provider.submit({ taskId: TASK_ID, files: ["result.md"], confirmation: "SUBMIT TASKMARKET WORK" })); + expect(result.success).toBe(true); + expect(submitWork).toHaveBeenCalledWith({ taskId: TASK_ID, files: ["result.md"] }); + }); + + it("returns structured API errors", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 503 }); + const result = JSON.parse(await taskmarketActionProvider().getTask({ taskId: TASK_ID })); + expect(result.details).toBe("HTTP 503"); + }); +}); + 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..088b8a41b --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { + AnalyzeTaskmarketTaskSchema, + GetTaskmarketTaskSchema, + ListTaskmarketTasksSchema, + SubmitTaskmarketWorkSchema, + TaskmarketConfig, +} from "./schemas"; + +const DEFAULT_API_URL = "https://api.taskmarket.dev"; +const SUBMISSION_CONFIRMATION = "SUBMIT TASKMARKET WORK"; + +type TaskmarketTask = { + id: string; + reward: string; + netReward?: string | null; + platformFeeBps: number; + submissionCount?: number; + status: string; + phase: string; + submissionWindowOpen: boolean; + pendingActions?: Array<{ action: string; actor: string; requiresPayment?: boolean }>; +}; + +/** Provides guarded Taskmarket discovery, analysis, and submission actions. */ +export class TaskmarketActionProvider extends ActionProvider { + private readonly apiUrl: string; + private readonly allowSubmissions: boolean; + private readonly submitWork?: TaskmarketConfig["submitWork"]; + + constructor(config: TaskmarketConfig = {}) { + super("taskmarket", []); + this.apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, ""); + this.allowSubmissions = config.allowSubmissions ?? false; + this.submitWork = config.submitWork; + } + + @CreateAction({ + name: "list_tasks", + description: "List open Taskmarket work opportunities. This is read-only and never spends funds.", + schema: ListTaskmarketTasksSchema, + }) + async listTasks(args: z.infer): Promise { + const params = new URLSearchParams({ status: "open", limit: String(args.limit) }); + if (args.mode) params.set("mode", args.mode); + if (args.tags?.length) params.set("tags", args.tags.join(",")); + if (args.minRewardUsdc !== null) { + params.set("minReward", String(Math.round(args.minRewardUsdc * 1_000_000))); + } + if (args.deadlineHours !== null) params.set("deadlineHours", String(args.deadlineHours)); + return this.get(`/api/tasks?${params.toString()}`, "Failed to list Taskmarket tasks"); + } + + @CreateAction({ + name: "get_task", + description: "Inspect current Taskmarket task terms and pending actions before doing work.", + schema: GetTaskmarketTaskSchema, + }) + async getTask(args: z.infer): Promise { + return this.get(`/api/tasks/${args.taskId}`, "Failed to fetch Taskmarket task"); + } + + @CreateAction({ + name: "analyze_task_economics", + description: "Calculate net reward, competition-adjusted expected value, and expected hourly value. Read-only; estimates are not guarantees.", + schema: AnalyzeTaskmarketTaskSchema, + }) + async analyzeTaskEconomics(args: z.infer): Promise { + try { + const task = await this.fetchTask(args.taskId); + const grossRewardUsdc = Number(task.reward) / 1_000_000; + const netRewardUsdc = task.netReward + ? Number(task.netReward) / 1_000_000 + : grossRewardUsdc * (1 - task.platformFeeBps / 10_000); + const submissions = task.submissionCount ?? 0; + const probability = args.probabilityOfWinning ?? 1 / (submissions + 1); + const expectedValueUsdc = netRewardUsdc * probability; + return JSON.stringify({ + taskId: task.id, + grossRewardUsdc, + netRewardUsdc, + existingSubmissions: submissions, + probabilityOfWinning: probability, + expectedValueUsdc, + expectedHourlyUsdc: expectedValueUsdc / args.estimatedHours, + caveat: "Competition-adjusted expected value is an estimate, not guaranteed income.", + }, null, 2); + } catch (error) { + return this.error("Failed to analyze Taskmarket task", error); + } + } + + @CreateAction({ + name: "submit_work", + description: `Submit prepared files to an open Taskmarket task. Disabled by default. Never call without the user's explicit, task-specific approval and the exact confirmation phrase "${SUBMISSION_CONFIRMATION}".`, + schema: SubmitTaskmarketWorkSchema, + }) + async submit(args: z.infer): Promise { + if (!this.allowSubmissions || !this.submitWork) { + return JSON.stringify({ error: true, message: "Taskmarket submissions are disabled by host configuration." }); + } + if (args.confirmation !== SUBMISSION_CONFIRMATION) { + return JSON.stringify({ error: true, message: `Explicit confirmation required: ${SUBMISSION_CONFIRMATION}` }); + } + try { + const task = await this.fetchTask(args.taskId); + const canSubmit = task.status === "open" && task.submissionWindowOpen && + task.pendingActions?.some(action => action.action === "submit" && action.actor === "worker" && !action.requiresPayment); + if (!canSubmit) return JSON.stringify({ error: true, message: "Task is not currently eligible for a free worker submission." }); + const result = await this.submitWork({ taskId: args.taskId, files: args.files }); + return JSON.stringify({ success: true, taskId: args.taskId, result }, null, 2); + } catch (error) { + return this.error("Failed to submit Taskmarket work", error); + } + } + + supportsNetwork(): boolean { return true; } + + private async fetchTask(taskId: string): Promise { + const response = await fetch(`${this.apiUrl}/api/tasks/${taskId}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + } + + private async get(path: string, message: string): Promise { + try { + const response = await fetch(`${this.apiUrl}${path}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return JSON.stringify(await response.json(), null, 2); + } catch (error) { return this.error(message, error); } + } + + private error(message: string, error: unknown): string { + return JSON.stringify({ error: true, message, details: error instanceof Error ? error.message : String(error) }, null, 2); + } +} + +export const taskmarketActionProvider = (config?: TaskmarketConfig) => new TaskmarketActionProvider(config); +