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
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 @@ -27,6 +27,7 @@ export * from "./spl";
export * from "./superfluid";
export * from "./sushi";
export * from "./truemarkets";
export * from "./taskmarket";
export * from "./twitter";
export * from "./wallet";
export * from "./weth";
Expand Down
67 changes: 67 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# TaskMarket Action Provider

Lets an agent discover paid work on [TaskMarket](https://taskmarket.dev), a task
marketplace where requesters escrow USDC on Base and workers submit deliverables.

The point is delegation. An agent that recognises a request is better handled by
external workers can look for existing funded work instead of burning inference
on something it will do badly, and can hand its operator a link rather than an
unreliable answer.

## Actions

| Action | What it does |
| --- | --- |
| `browse_tasks` | Lists open tasks, sorted by net reward, with deadline and how contested each already is |
| `get_task_details` | Full acceptance criteria, reward, deadline and award count for one task |
| `evaluate_delegation` | Given a description of work in hand, surfaces open tasks that overlap with it |

## Setup

No API key, no wallet, no configuration:

```typescript
import { taskmarketActionProvider } from "@coinbase/agentkit";

const agentKit = await AgentKit.from({
walletProvider,
actionProviders: [taskmarketActionProvider()],
});
```

The discovery endpoints on the TaskMarket public API are unauthenticated and
read-only. `baseUrl` can be overridden for testing.

## Scope, and what is deliberately excluded

Every action here is **read-only**. This provider does not spend funds, touch a
wallet, create a task, claim work, accept submissions, or hold a key.

Creating a task and submitting work both move real USDC through escrow. Those
belong behind explicit human authorization, so they are intentionally left to the
first-party TaskMarket CLI rather than exposed as agent actions. An agent should
be able to tell its operator "this is already funded, here is the link" without
being able to spend on their behalf.

`evaluate_delegation` reflects this in its output: it returns candidates and
states plainly that acting on them requires operator authorization.

## Notes on the output

- Rewards are reported **net of the platform fee**, because that is what a
worker would actually receive.
- Submission counts are included deliberately. A 64 USDC task with 142
submissions is often worth less in expectation than a 4 USDC task with three,
and an agent recommending work should be able to see that.
- Descriptions are collapsed to one line in list views. Task descriptions run to
several hundred words of markdown, and pasting them verbatim into a context
window is the fastest way to make a discovery tool unusable.
- Matching in `evaluate_delegation` is transparent keyword overlap rather than
an embedding lookup, so it costs no extra model call and an operator can
predict what it will do.

## Network support

Network-agnostic. Browsing work reads a public HTTP API and touches no wallet.
Settlement happens in USDC on Base, but only once a user acts on a task outside
this provider.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Base URL for the TaskMarket public API.
*
* The discovery endpoints used by this action provider are unauthenticated and
* read-only, so no API key or wallet is required to browse work.
*/
export const TASKMARKET_BASE_URL = "https://api.taskmarket.dev/api";

/**
* Human-facing task pages, used to give the agent a link it can hand back to a
* user who wants to inspect or act on a task themselves.
*/
export const TASKMARKET_APP_URL = "https://taskmarket.dev";

/**
* USDC is denominated in 6 decimals on Base, which is how every reward and
* `netReward` value comes back from the API.
*/
export const USDC_DECIMALS = 6;

/**
* Ceiling on how many tasks a single browse call will return. Keeping this
* small matters: the results are pasted into a model context window, and an
* unbounded list is both expensive and unreadable.
*/
export const MAX_TASKS_RETURNED = 25;

/**
* Default request timeout in milliseconds.
*/
export const REQUEST_TIMEOUT_MS = 15_000;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./taskmarketActionProvider";
export * from "./schemas";
export * from "./types";
export * from "./constants";
export * from "./utils";
58 changes: 58 additions & 0 deletions typescript/agentkit/src/action-providers/taskmarket/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { z } from "zod";

import { MAX_TASKS_RETURNED } from "./constants";

/**
* Input schema for browsing open TaskMarket work.
*/
export const BrowseTasksSchema = z
.object({
limit: z
.number()
.int()
.positive()
.max(MAX_TASKS_RETURNED)
.default(10)
.describe(`Maximum number of tasks to return (1-${MAX_TASKS_RETURNED}).`),
minRewardUsdc: z
.number()
.nonnegative()
.optional()
.describe("Only return tasks whose net reward is at least this many USDC."),
keyword: z
.string()
.optional()
.describe("Case-insensitive substring to match against the task description."),
})
.describe("Input schema for browsing open work on TaskMarket");

/**
* Input schema for fetching a single task by id.
*/
export const GetTaskSchema = z
.object({
taskId: z
.string()
.regex(/^0x[a-fA-F0-9]{64}$/, "Task id must be a 0x-prefixed 32-byte hex string.")
.describe("The TaskMarket task id, a 0x-prefixed 32-byte hex string."),
})
.describe("Input schema for retrieving one TaskMarket task");

/**
* Input schema for the delegation check.
*/
export const EvaluateDelegationSchema = z
.object({
workDescription: z
.string()
.min(3)
.describe("A short description of the work you are considering delegating."),
limit: z
.number()
.int()
.positive()
.max(MAX_TASKS_RETURNED)
.default(5)
.describe("Maximum number of candidate tasks to return."),
})
.describe("Input schema for checking whether open TaskMarket work matches a job at hand");
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { TaskMarketActionProvider } from "./taskmarketActionProvider";
import { hoursUntil, isOpenForWork, matchScore, summarize, toUsdc } from "./utils";
import { TaskMarketTask } from "./types";

const HOUR = 3_600_000;

/**
* Builds a task fixture with sensible open defaults.
*
* @param over - Fields to override on the fixture.
* @returns A task object.
*/
function task(over: Partial<TaskMarketTask> = {}): TaskMarketTask {
return {
id: `0x${"a".repeat(64)}`,
description: "Build a video pipeline for onchain agents",
status: "open",
netReward: 20_000_000,
submissionCount: 3,
expiryTime: new Date(Date.now() + 48 * HOUR).toISOString(),
submissionWindowOpen: true,
tags: ["video", "agents"],
...over,
};
}

describe("TaskMarket utils", () => {
it("converts USDC base units from both strings and numbers", () => {
expect(toUsdc(20_000_000)).toBe(20);
expect(toUsdc("1500000")).toBe(1.5);
expect(toUsdc(undefined)).toBe(0);
expect(toUsdc("not-a-number")).toBe(0);
});

it("computes hours remaining and tolerates bad input", () => {
const soon = new Date(Date.now() + 2 * HOUR).toISOString();
expect(hoursUntil(soon)).toBeGreaterThan(1.5);
expect(hoursUntil(soon)).toBeLessThan(2.5);
expect(hoursUntil(undefined)).toBeNull();
expect(hoursUntil("nonsense")).toBeNull();
});

it("collapses long descriptions to a single line", () => {
const out = summarize("a".repeat(500));
expect(out.length).toBeLessThanOrEqual(140);
expect(summarize("short\n\ndescription")).toBe("short description");
});

it("treats expired or closed tasks as not open, even when status says open", () => {
expect(isOpenForWork(task())).toBe(true);
expect(isOpenForWork(task({ status: "closed" }))).toBe(false);
expect(isOpenForWork(task({ submissionWindowOpen: false }))).toBe(false);
expect(
isOpenForWork(task({ expiryTime: new Date(Date.now() - HOUR).toISOString() })),
).toBe(false);
});

it("scores keyword overlap and ignores stopwords", () => {
expect(matchScore(task(), "video pipeline")).toBe(2);
expect(matchScore(task(), "the and for with")).toBe(0);
expect(matchScore(task(), "quantum knitting")).toBe(0);
});
});

describe("TaskMarketActionProvider", () => {
const provider = new TaskMarketActionProvider({ baseUrl: "https://example.test/api" });
const fetchMock = jest.fn();

beforeEach(() => {
fetchMock.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});

it("supports every network, since discovery touches no wallet", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm" })).toBe(true);
expect(provider.supportsNetwork({ protocolFamily: "svm" })).toBe(true);
});

it("lists open tasks sorted by reward", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
tasks: [
task({ id: `0x${"1".repeat(64)}`, netReward: 1_000_000 }),
task({ id: `0x${"2".repeat(64)}`, netReward: 64_000_000 }),
],
}),
});

const out = await provider.browseTasks({ limit: 10 });
expect(out).toContain("64.000 USDC");
expect(out.indexOf("64.000 USDC")).toBeLessThan(out.indexOf("1.000 USDC"));
});

it("filters out tasks below the reward floor", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tasks: [task({ netReward: 500_000 })] }),
});

const out = await provider.browseTasks({ limit: 10, minRewardUsdc: 5 });
expect(out).toContain("No open TaskMarket tasks matched");
});

it("excludes expired tasks from browse results", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
tasks: [task({ expiryTime: new Date(Date.now() - HOUR).toISOString() })],
}),
});

const out = await provider.browseTasks({ limit: 10 });
expect(out).toContain("No open TaskMarket tasks matched");
});

it("returns an error string rather than throwing on API failure", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 503 });
const out = await provider.browseTasks({ limit: 5 });
expect(out).toContain("Could not browse TaskMarket tasks");
expect(out).toContain("503");
});

it("surfaces matching work for delegation and refuses to act on it", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tasks: [task()] }),
});

const out = await provider.evaluateDelegation({
workDescription: "produce a video for an agent",
limit: 5,
});
expect(out).toContain("overlap with that work");
expect(out).toContain("explicit");
});

it("reports plainly when nothing matches", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tasks: [task()] }),
});

const out = await provider.evaluateDelegation({
workDescription: "underwater basket weaving",
limit: 5,
});
expect(out).toContain("does not appear to be already funded");
});

it("renders full task detail", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => task({ description: "Acceptance criteria here" }),
});

const out = await provider.getTaskDetails({ taskId: `0x${"a".repeat(64)}` });
expect(out).toContain("Acceptance criteria here");
expect(out).toContain("Net reward: 20.000000 USDC");
});
});
Loading
Loading