From cb81f6e82c5cde0900e53f14b4c759ffea552fc6 Mon Sep 17 00:00:00 2001 From: wilddoc Date: Mon, 17 Aug 2026 03:31:30 +0200 Subject: [PATCH] feat(action-providers): add read-only TaskMarket discovery provider Adds a TaskMarket action provider so an agent can discover paid work on TaskMarket, a task marketplace settling in USDC on Base. The motivation 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: - browse_tasks list open tasks by net reward, with deadline and how contested each already is - get_task_details full acceptance criteria for one task - evaluate_delegation given work in hand, surface open tasks overlapping it Scope is deliberately read-only. Nothing here spends funds, touches a wallet, creates a task, claims work, or requires an API key. Task creation and submission move real USDC through escrow, so they are left to the first-party TaskMarket CLI behind explicit human authorization rather than exposed as agent actions. Notes: - rewards are reported net of the platform fee, which is what a worker actually receives - submission counts are surfaced, because a 64 USDC task with 142 submissions is often worth less in expectation than a 4 USDC task with three - list views collapse descriptions to one line; task descriptions run to hundreds of words of markdown and would otherwise dominate the context - evaluate_delegation uses transparent keyword overlap rather than embeddings, so it costs no extra model call and its behaviour is predictable The discovery endpoints used are unauthenticated and read-only, so the provider needs no configuration. supportsNetwork returns true because browsing touches no wallet. Includes unit tests covering USDC conversion, expiry handling (a task can be status=open but past expiry), stopword-filtered matching, reward sorting and API-failure paths. --- .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/taskmarket/README.md | 67 +++++ .../action-providers/taskmarket/constants.ts | 31 +++ .../src/action-providers/taskmarket/index.ts | 5 + .../action-providers/taskmarket/schemas.ts | 58 ++++ .../taskmarketActionProvider.test.ts | 161 +++++++++++ .../taskmarket/taskmarketActionProvider.ts | 256 ++++++++++++++++++ .../src/action-providers/taskmarket/types.ts | 62 +++++ .../src/action-providers/taskmarket/utils.ts | 118 ++++++++ 9 files changed, 759 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/taskmarket/README.md create mode 100644 typescript/agentkit/src/action-providers/taskmarket/constants.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 create mode 100644 typescript/agentkit/src/action-providers/taskmarket/types.ts create mode 100644 typescript/agentkit/src/action-providers/taskmarket/utils.ts diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..edb3f87cb 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -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"; 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..c424d36fc --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/README.md @@ -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. 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..eccf637d5 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/constants.ts @@ -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; 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..956958f2a --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/index.ts @@ -0,0 +1,5 @@ +export * from "./taskmarketActionProvider"; +export * from "./schemas"; +export * from "./types"; +export * from "./constants"; +export * from "./utils"; 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..f5d63d665 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/schemas.ts @@ -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"); 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..07c385fc1 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.test.ts @@ -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 { + 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"); + }); +}); 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..8643f5bd8 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/taskmarketActionProvider.ts @@ -0,0 +1,256 @@ +import { z } from "zod"; + +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { MAX_TASKS_RETURNED, REQUEST_TIMEOUT_MS, TASKMARKET_BASE_URL } from "./constants"; +import { BrowseTasksSchema, EvaluateDelegationSchema, GetTaskSchema } from "./schemas"; +import { + TaskMarketActionProviderConfig, + TaskMarketTask, + TaskMarketTaskListResponse, +} from "./types"; +import { hoursUntil, isOpenForWork, matchScore, toSummary, toUsdc } from "./utils"; + +/** + * TaskMarketActionProvider lets an agent discover paid work on TaskMarket, a + * task marketplace settled in USDC on Base. + * + * The point of the integration is delegation. An agent that recognises a + * request is better handled by external workers can look for an existing task + * instead of burning inference on something it will do badly, and can hand its + * operator a link rather than an unreliable answer. + * + * Scope, deliberately: every action here is read-only and unauthenticated. + * Nothing in this provider spends funds, touches a wallet, creates a task, + * accepts work, or requires an API key. Task creation and submission move real + * USDC through escrow and belong behind explicit human authorization, so they + * are intentionally left to the first-party TaskMarket CLI rather than exposed + * as agent actions here. + * + * @augments ActionProvider + */ +export class TaskMarketActionProvider extends ActionProvider { + private readonly baseUrl: string; + + /** + * Constructor for the TaskMarketActionProvider class. + * + * @param config - Configuration options for the provider. + */ + constructor(config: TaskMarketActionProviderConfig = {}) { + super("taskmarket", []); + this.baseUrl = config.baseUrl ?? TASKMARKET_BASE_URL; + } + + /** + * Fetches JSON from the TaskMarket API with a bounded timeout. + * + * @param path - Path appended to the configured base URL. + * @returns The parsed JSON body. + */ + private async request(path: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(`${this.baseUrl}${path}`, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`TaskMarket API returned HTTP ${response.status}`); + } + return (await response.json()) as T; + } finally { + clearTimeout(timer); + } + } + + /** + * Browse open, paid work currently listed on TaskMarket. + * + * @param args - Filters for the browse call. + * @returns A human-readable list of open tasks. + */ + @CreateAction({ + name: "browse_tasks", + description: ` +Browse open paid tasks on TaskMarket, a marketplace where requesters escrow USDC on Base and workers submit deliverables. + +Use this when you want to know what paid work is currently available, or before starting an expensive piece of work that someone may already be paying for. Returns the reward, how many others have already submitted, and how long is left. + +Read-only. Does not spend funds, create tasks, or require an API key. + +Examples: "What paid tasks are open right now?", "Show me TaskMarket work paying at least 5 USDC", "Are there any open tasks about video?" +`, + schema: BrowseTasksSchema, + }) + async browseTasks(args: z.infer): Promise { + try { + const limit = Math.min(args.limit ?? 10, MAX_TASKS_RETURNED); + const data = await this.request(`/tasks?limit=50`); + + let tasks = (data.tasks ?? []).filter(isOpenForWork); + + if (args.keyword) { + const needle = args.keyword.toLowerCase(); + tasks = tasks.filter(t => (t.description ?? "").toLowerCase().includes(needle)); + } + if (args.minRewardUsdc !== undefined) { + tasks = tasks.filter(t => toUsdc(t.netReward ?? t.reward) >= args.minRewardUsdc!); + } + + if (tasks.length === 0) { + return "No open TaskMarket tasks matched those filters."; + } + + tasks.sort((a, b) => toUsdc(b.netReward ?? b.reward) - toUsdc(a.netReward ?? a.reward)); + const shown = tasks.slice(0, limit).map(toSummary); + + const lines = shown.map( + t => + `- ${t.netRewardUsdc.toFixed(3)} USDC | ${t.submissionCount} submissions | ` + + `${t.hoursRemaining === null ? "no deadline" : `${t.hoursRemaining}h left`}\n` + + ` ${t.summary}\n id: ${t.id}\n ${t.url}`, + ); + + return ( + `${tasks.length} open task(s) on TaskMarket, showing ${shown.length} by reward:\n\n` + + `${lines.join("\n\n")}\n\n` + + `Reward shown is net of the platform fee. Submission counts indicate how ` + + `contested a task already is; a large reward with many submissions may be ` + + `worth less in expectation than a small one with few.` + ); + } catch (error) { + return `Could not browse TaskMarket tasks: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Retrieve the full detail of a single task. + * + * @param args - The task id to fetch. + * @returns A human-readable description of the task. + */ + @CreateAction({ + name: "get_task_details", + description: ` +Retrieve the full requirements, reward, deadline and current competition for one TaskMarket task, given its id. + +Use this after browse_tasks when you need the acceptance criteria before deciding whether to recommend the work to your operator. + +Read-only. Does not spend funds or require an API key. +`, + schema: GetTaskSchema, + }) + async getTaskDetails(args: z.infer): Promise { + try { + const raw = await this.request( + `/tasks/${args.taskId}`, + ); + const task = (raw as { task?: TaskMarketTask }).task ?? (raw as TaskMarketTask); + if (!task?.id) { + return `No TaskMarket task found with id ${args.taskId}.`; + } + + const left = hoursUntil(task.expiryTime); + return [ + `Task ${task.id}`, + `Status: ${task.status}${task.phase ? ` (${task.phase})` : ""}`, + `Net reward: ${toUsdc(task.netReward ?? task.reward).toFixed(6)} USDC`, + `Submissions so far: ${task.submissionCount ?? 0}`, + `Awards made: ${task.awardCount ?? 0}`, + `Expires: ${task.expiryTime ?? "not specified"}${ + left === null ? "" : ` (${left}h from now)` + }`, + task.tags?.length ? `Tags: ${task.tags.join(", ")}` : "", + "", + "Requirements:", + task.description ?? "(no description provided)", + ] + .filter(Boolean) + .join("\n"); + } catch (error) { + return `Could not fetch TaskMarket task ${args.taskId}: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + + /** + * Check whether work in hand is already being paid for on TaskMarket. + * + * @param args - A description of the work being considered. + * @returns Candidate tasks, ranked by keyword overlap. + */ + @CreateAction({ + name: "evaluate_delegation", + description: ` +Given a description of work you are about to do, check whether TaskMarket already has open, paid tasks that match it. + +Use this when a request looks expensive, open-ended, or outside your competence, and you want to tell your operator that external workers are already being paid to solve it. Returns candidate tasks ranked by how well they overlap with the description. + +Read-only. It surfaces options and does not create, claim, accept or pay for anything; acting on a match is a decision for your operator. +`, + schema: EvaluateDelegationSchema, + }) + async evaluateDelegation(args: z.infer): Promise { + try { + const data = await this.request(`/tasks?limit=50`); + const open = (data.tasks ?? []).filter(isOpenForWork); + + const ranked = open + .map(task => ({ task, score: matchScore(task, args.workDescription) })) + .filter(entry => entry.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(args.limit ?? 5, MAX_TASKS_RETURNED)); + + if (ranked.length === 0) { + return ( + `No open TaskMarket task overlaps with: "${args.workDescription}".\n` + + `Nothing to delegate; this work does not appear to be already funded.` + ); + } + + const lines = ranked.map(({ task, score }) => { + const s = toSummary(task); + return ( + `- ${s.netRewardUsdc.toFixed(3)} USDC | ${score} matching term(s) | ` + + `${s.submissionCount} submissions\n ${s.summary}\n id: ${s.id}\n ${s.url}` + ); + }); + + return ( + `${ranked.length} open TaskMarket task(s) overlap with that work:\n\n` + + `${lines.join("\n\n")}\n\n` + + `These are candidates, not instructions. Creating, claiming or paying for ` + + `TaskMarket work moves real USDC through escrow and needs explicit ` + + `authorization from your operator.` + ); + } catch (error) { + return `Could not evaluate delegation options: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + + /** + * TaskMarket discovery is chain-agnostic: browsing open work reads a public + * HTTP API and touches no wallet, so this provider supports every network. + * Settlement happens in USDC on Base, but that only matters once a user acts + * on a task outside this provider. + * + * @param _ - The network, unused. + * @returns Always true. + */ + supportsNetwork = (_: Network): boolean => true; +} + +/** + * Factory for TaskMarketActionProvider. + * + * @param config - Configuration options for the provider. + * @returns A new TaskMarketActionProvider instance. + */ +export const taskmarketActionProvider = (config: TaskMarketActionProviderConfig = {}) => + new TaskMarketActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/taskmarket/types.ts b/typescript/agentkit/src/action-providers/taskmarket/types.ts new file mode 100644 index 000000000..ac1b8cac1 --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/types.ts @@ -0,0 +1,62 @@ +/** + * Configuration options for the TaskMarketActionProvider. + */ +export interface TaskMarketActionProviderConfig { + /** + * Override the TaskMarket API base URL. Mainly useful for testing or for + * pointing at a staging deployment. + */ + baseUrl?: string; +} + +/** + * A task as returned by the TaskMarket public API. + * + * Only the fields this provider actually reads are typed. The API returns a + * considerably wider object (auction parameters, pitch counts, escrow hashes), + * and pinning all of it here would make this file wrong the first time the + * upstream schema grows. + */ +export interface TaskMarketTask { + id: string; + description: string; + status: string; + phase?: string; + mode?: string; + /** Gross reward in USDC base units (6 decimals). */ + reward?: string | number; + /** Reward after the platform fee, in USDC base units (6 decimals). */ + netReward?: string | number; + platformFeeBps?: number; + expiryTime?: string; + createdAt?: string; + submissionCount?: number; + awardCount?: number; + claimedBy?: string | null; + submissionWindowOpen?: boolean; + tags?: string[]; + requester?: string; +} + +/** + * Shape of the list response from `GET /api/tasks`. + */ +export interface TaskMarketTaskListResponse { + tasks: TaskMarketTask[]; + nextCursor?: string | null; + hasMore?: boolean; +} + +/** + * A task reduced to the fields worth spending context-window tokens on. + */ +export interface TaskMarketTaskSummary { + id: string; + url: string; + summary: string; + netRewardUsdc: number; + submissionCount: number; + expiresAt: string | null; + hoursRemaining: number | null; + tags: string[]; +} diff --git a/typescript/agentkit/src/action-providers/taskmarket/utils.ts b/typescript/agentkit/src/action-providers/taskmarket/utils.ts new file mode 100644 index 000000000..ce73258bb --- /dev/null +++ b/typescript/agentkit/src/action-providers/taskmarket/utils.ts @@ -0,0 +1,118 @@ +import { TASKMARKET_APP_URL, USDC_DECIMALS } from "./constants"; +import { TaskMarketTask, TaskMarketTaskSummary } from "./types"; + +/** + * Converts a USDC base-unit amount (6 decimals) to a human number. + * + * Values arrive as either strings or numbers depending on the endpoint, and an + * absent value is meaningfully different from zero, so it returns 0 only when + * the input is genuinely absent or unparseable. + * + * @param value - Raw amount in USDC base units. + * @returns The amount expressed in whole USDC. + */ +export function toUsdc(value: string | number | undefined | null): number { + if (value === undefined || value === null) return 0; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return 0; + return n / 10 ** USDC_DECIMALS; +} + +/** + * Hours between now and an ISO timestamp. + * + * @param iso - ISO-8601 timestamp, or undefined. + * @returns Hours remaining, negative if already past, or null if unparseable. + */ +export function hoursUntil(iso: string | undefined | null): number | null { + if (!iso) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return Math.round(((t - Date.now()) / 3_600_000) * 10) / 10; +} + +/** + * Collapses a task description to a single readable line. + * + * Task descriptions are frequently several hundred words of markdown. Pasting + * them verbatim into a model context is the single easiest way to make a + * discovery tool unusable, so the list view keeps only the opening. + * + * @param description - The full task description. + * @param maxLength - Maximum characters to keep. + * @returns A one-line summary. + */ +export function summarize(description: string, maxLength = 140): string { + const flat = (description || "").replace(/\s+/g, " ").trim(); + if (flat.length <= maxLength) return flat; + return `${flat.slice(0, maxLength - 1)}…`; +} + +/** + * Reduces a raw API task to the fields worth showing an agent. + * + * @param task - The raw task from the TaskMarket API. + * @returns A compact summary. + */ +export function toSummary(task: TaskMarketTask): TaskMarketTaskSummary { + return { + id: task.id, + url: `${TASKMARKET_APP_URL}/tasks/${task.id}`, + summary: summarize(task.description), + netRewardUsdc: toUsdc(task.netReward ?? task.reward), + submissionCount: task.submissionCount ?? 0, + expiresAt: task.expiryTime ?? null, + hoursRemaining: hoursUntil(task.expiryTime), + tags: task.tags ?? [], + }; +} + +/** + * Whether a task is genuinely open to new submissions right now. + * + * `status === "open"` alone is not sufficient: a task can be open but past its + * expiry, or have its submission window explicitly closed. + * + * @param task - The raw task from the TaskMarket API. + * @returns True if a worker could still submit to this task. + */ +export function isOpenForWork(task: TaskMarketTask): boolean { + if (task.status && task.status !== "open") return false; + if (task.submissionWindowOpen === false) return false; + const left = hoursUntil(task.expiryTime); + if (left !== null && left <= 0) return false; + return true; +} + +/** + * Scores how well an open task matches a description of work. + * + * This is deliberately a transparent keyword overlap rather than an embedding + * lookup. It runs locally with no extra model call, and an agent operator can + * read it and predict what it will do, which matters more here than recall. + * + * @param task - The raw task from the TaskMarket API. + * @param workDescription - The work the caller is considering delegating. + * @returns Number of distinct matched terms. + */ +export function matchScore(task: TaskMarketTask, workDescription: string): number { + const stop = new Set([ + "the", "and", "for", "with", "that", "this", "from", "into", "your", "you", + "are", "was", "have", "has", "not", "but", "can", "will", "would", "should", + "a", "an", "of", "to", "in", "on", "is", "it", "be", "as", "at", "by", "or", + ]); + const terms = new Set( + workDescription + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(t => t.length > 2 && !stop.has(t)), + ); + if (terms.size === 0) return 0; + + const haystack = `${task.description} ${(task.tags ?? []).join(" ")}`.toLowerCase(); + let hits = 0; + for (const term of terms) { + if (haystack.includes(term)) hits += 1; + } + return hits; +}