Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/bright-tasks-earn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a guarded Taskmarket action provider for task discovery, economics analysis, and explicitly authorized submissions.
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
21 changes: 21 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/README.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./schemas";
export * from "./taskmarketActionProvider";

37 changes: 37 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
}

Original file line number Diff line number Diff line change
@@ -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");
});
});

Original file line number Diff line number Diff line change
@@ -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<typeof ListTaskmarketTasksSchema>): Promise<string> {
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<typeof GetTaskmarketTaskSchema>): Promise<string> {
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<typeof AnalyzeTaskmarketTaskSchema>): Promise<string> {
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<typeof SubmitTaskmarketWorkSchema>): Promise<string> {
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<TaskmarketTask> {
const response = await fetch(`${this.apiUrl}/api/tasks/${taskId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<TaskmarketTask>;
}

private async get(path: string, message: string): Promise<string> {
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);

Loading