diff --git a/package.json b/package.json index 5520444..73661f9 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "build": "tsc && node scripts/copy-plugin-assets.mjs", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs test/repair.mjs", + "test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs test/repair.mjs test/market.local.mjs", "test:e2e": "npm run build && node --test --test-reporter=spec test/e2e.mjs", "test:free-models": "npm run build && node --test --test-reporter=spec test/free-model-matrix.mjs", "test:all": "npm run test && npm run test:e2e", diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 0f032e2..2a14cac 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -259,6 +259,7 @@ const DIRECT_COMMANDS: Record Promise | v ` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /session-search /context /tasks /history /transcript\n` + ` **Power:** /ultrathink [query] /ultraplan /noplan /moa [query] /dump\n` + ` **Info:** /model /auto /wallet /cost /tokens /learnings /brain /mcp /doctor /version /bug /help\n` + + ` **Market:** /market [keyword] · /market info · /market run \n` + ` **UI:** /clear /exit\n` + skillsBlock + (ultrathinkOn ? `\n Ultrathink: ON\n` : '') @@ -638,6 +639,70 @@ export async function handleSlashCommand( return { handled: true }; } + // /market [keyword | info | run ] — browse + hire paid + // skills from the BlockRun agent marketplace (business.blockrun.ai). Browsing + // and search are free GETs; `run` pays ONE standard x402 from the wallet. + if (input === '/market' || input.startsWith('/market ')) { + const arg = input.slice('/market'.length).trim(); + const { fetchCatalog, runMarketSkill, formatCatalogList, formatSkillCard, fmtUsd } = + await import('../market/client.js'); + + // run — the only paid path. + if (/^run(\s|$)/.test(arg)) { + const runMatch = arg.match(/^run\s+(\S+)\s+([\s\S]+)$/); + if (!runMatch) { + ctx.onEvent({ kind: 'text_delta', text: 'Usage: /market run \n' }); + emitDone(ctx); + return { handled: true }; + } + const [, slug, skillInput] = runMatch; + ctx.onEvent({ kind: 'text_delta', text: `Hiring ${slug}…\n` }); + const outcome = await runMarketSkill(slug, skillInput); + if (!outcome.ok) { + ctx.onEvent({ kind: 'text_delta', text: `Could not run ${slug}: ${outcome.error}. No charge.\n` }); + } else { + const tx = outcome.txHash ? ` . tx ${outcome.txHash.slice(0, 12)}…` : ''; + ctx.onEvent({ kind: 'text_delta', text: `Paid ${fmtUsd(outcome.paidUsd)}${tx}\n\n${outcome.result ?? ''}\n` }); + } + emitDone(ctx); + return { handled: true }; + } + + // info — detail card. + if (/^info(\s|$)/.test(arg)) { + const slug = arg.slice('info'.length).trim(); + if (!slug) { + ctx.onEvent({ kind: 'text_delta', text: 'Usage: /market info \n' }); + emitDone(ctx); + return { handled: true }; + } + try { + const skills = await fetchCatalog({ limit: 200 }); + const skill = skills.find((s) => s.slug === slug); + ctx.onEvent({ kind: 'text_delta', text: skill ? formatSkillCard(skill) : `No skill "${slug}" in the marketplace.\n` }); + } catch (err) { + ctx.onEvent({ kind: 'text_delta', text: `Could not reach the marketplace: ${(err as Error).message}\n` }); + } + emitDone(ctx); + return { handled: true }; + } + + // (no arg) browse the top skills, or to search — both free. + try { + const TOP = 12; + const skills = await fetchCatalog({ limit: 200, query: arg || undefined }); + const shown = arg ? skills : skills.slice(0, TOP); + const heading = arg + ? `Agent talents — ${shown.length} skill(s) matching "${arg}":` + : `Agent talents — top ${shown.length}${skills.length > TOP ? ` of ${skills.length}` : ''}:`; + ctx.onEvent({ kind: 'text_delta', text: formatCatalogList(shown, { heading }) }); + } catch (err) { + ctx.onEvent({ kind: 'text_delta', text: `Could not reach the marketplace: ${(err as Error).message}\n` }); + } + emitDone(ctx); + return { handled: true }; + } + // /insights [--days N] — rich usage insights if (input === '/insights' || input.startsWith('/insights ')) { const daysMatch = input.match(/--days\s+(\d+)/); @@ -1050,7 +1115,7 @@ export async function handleSlashCommand( ...Object.keys(REWRITE_COMMANDS), ...ARG_COMMANDS.map(c => c.prefix.trim()), ...skillNames, - '/branch', '/resume', '/model', '/auto', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit', '/session-search', '/ssearch', '/failures', + '/branch', '/resume', '/model', '/auto', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit', '/session-search', '/ssearch', '/failures', '/market', ]; const cmd = input.split(/\s/)[0]; const close = allCommands.filter(c => { diff --git a/src/agent/permissions.ts b/src/agent/permissions.ts index a042a76..442b389 100644 --- a/src/agent/permissions.ts +++ b/src/agent/permissions.ts @@ -172,6 +172,17 @@ export class PermissionManager { return { behavior: 'ask' }; } + // agent_talent: browsing the marketplace is a free read (auto-allow); + // hiring (action="run") spends USDC from the wallet and has no refund, so + // it asks — same policy as the other paid, irreversible tools (VoiceCall, + // BuyPhoneNumber). describeAction spells out the spend in the prompt. + if (toolName === 'agent_talent') { + const action = typeof input.action === 'string' ? input.action.toLowerCase() : ''; + return action === 'run' + ? { behavior: 'ask' } + : { behavior: 'allow', reason: 'free marketplace browse' }; + } + // Default: read-only tools are auto-allowed, others ask if (READ_ONLY_TOOLS.has(toolName)) { return { behavior: 'allow', reason: 'read-only default' }; @@ -390,6 +401,13 @@ export class PermissionManager { } case 'Agent': return `Launch sub-agent: ${(input.description as string) || (input.prompt as string)?.slice(0, 80) || 'task'}`; + case 'agent_talent': { + if (((input.action as string) || '').toLowerCase() === 'run') { + const slug = (input.slug as string) || 'a skill'; + return `Hire '${slug}' from the agent marketplace — pays from your wallet (USDC on Base), charged only on a successful run.`; + } + return 'Browse the agent marketplace (free).'; + } default: return JSON.stringify(input).slice(0, 120); } diff --git a/src/config.ts b/src/config.ts index 7a0a350..433636a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,13 @@ export const API_URLS: Record = { export const DEFAULT_PROXY_PORT = 8402; +// BlockRun agent-market (the paid skill marketplace Franklin browses with +// `/market` and hires with the agent_talent tool). It speaks standard +// single-leg `exact` x402 on Base, so Franklin pays it with the same EVM +// wallet it uses for the gateway. Overridable via env for local end-to-end +// testing against a dev server. +export const MARKET_URL = (process.env.BLOCKRUN_MARKET_URL || 'https://business.blockrun.ai').replace(/\/+$/, ''); + export function saveChain(chain: Chain): void { fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); fs.writeFileSync(CHAIN_FILE, chain + '\n', { mode: 0o600 }); diff --git a/src/market/client.ts b/src/market/client.ts new file mode 100644 index 0000000..f3580e7 --- /dev/null +++ b/src/market/client.ts @@ -0,0 +1,264 @@ +/** + * BlockRun agent-market client — the shared engine behind the `/market` + * slash command (a human browsing + hiring talent) and the agent_talent + * tool (the agent hiring talent autonomously, mid-task). + * + * The market is business.blockrun.ai: a catalog of paid AI skills, each + * runnable for ONE standard single-leg `exact` x402 USDC payment on Base. + * Discovery is a free public GET; running a skill is a paid POST that + * answers a 402 challenge with a wallet-signed payment — the same dance as + * the gateway capability in src/tools/blockrun.ts, only the payment header + * name (`x-payment`) and the base URL differ. + * + * Base-only by design: the market settles USDC on Base, so a hire always + * pays from the EVM wallet (getOrCreateWallet) regardless of the session's + * configured chain. + */ + +import { + getOrCreateWallet, + createPaymentPayload, + extractPaymentDetails, +} from '@blockrun/llm'; +import { MARKET_URL, USER_AGENT } from '../config.js'; + +const DEFAULT_TIMEOUT_MS = 45_000; +const MAX_TIMEOUT_MS = 120_000; + +// extractPaymentDetails accepts a parsed PaymentRequired; that interface is +// not exported, so borrow the function's own parameter type for the cast. +type PaymentRequiredLike = Parameters[0]; + +export interface MarketSkill { + slug: string; + name: string; + description: string; + price_usd: number; + backing_model: string; + run_count: number; + execution_type: 'prompt' | 'agent' | string; + /** Live-data hostnames an `agent` skill fetches at run time (empty for `prompt`). */ + data_sources: string[]; + sample_input?: string; + sample_output?: string; + creator: { wallet: string; x: string | null }; + run_url: string; +} + +interface CatalogResponse { skills?: MarketSkill[] } + +// ─── Discovery (free) ─────────────────────────────────────────────────────── + +/** GET the public catalog. No payment. Optional client-side keyword filter. */ +export async function fetchCatalog( + opts: { limit?: number; query?: string; signal?: AbortSignal } = {}, +): Promise { + const limit = Math.min(Math.max(opts.limit ?? 100, 1), 200); + const url = `${MARKET_URL}/api/v1/skills?limit=${limit}`; + + const ctrl = new AbortController(); + const onAbort = () => ctrl.abort(); + opts.signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json', 'User-Agent': USER_AGENT }, + signal: ctrl.signal, + }); + if (!res.ok) throw new Error(`marketplace returned HTTP ${res.status}`); + const body = (await res.json()) as CatalogResponse; + const skills = Array.isArray(body.skills) ? body.skills : []; + // Rank by call volume, highest first — the catalog's ordering contract for + // every surface (the /market list, the agent_talent tool, the web panel). + // The discovery API already sorts this way; enforce it here too so the + // top-N slice is correct regardless. Stable sort keeps the API's recency + // tiebreak within an equal call count. + skills.sort((a, b) => (b.run_count ?? 0) - (a.run_count ?? 0)); + return opts.query ? filterCatalog(skills, opts.query) : skills; + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', onAbort); + } +} + +/** Filter the catalog by the fields a buyer searches on (AND over terms). */ +export function filterCatalog(skills: MarketSkill[], query: string): MarketSkill[] { + const q = query.trim().toLowerCase(); + if (!q) return skills; + const terms = q.split(/\s+/); + return skills.filter((s) => { + const hay = [s.slug, s.name, s.description, s.backing_model, ...(s.data_sources || [])] + .join(' ') + .toLowerCase(); + return terms.every((t) => hay.includes(t)); + }); +} + +// ─── Hire (paid) ──────────────────────────────────────────────────────────── + +export interface RunOutcome { + ok: boolean; + status: number; + result?: string; + /** USD authorized on the paid attempt (0 if unpaid / pre-payment 4xx). */ + paidUsd: number; + txHash: string | null; + error?: string; +} + +async function signMarketPayment( + challenge: PaymentRequiredLike, + runUrl: string, + skillName: string, +): Promise<{ header: string; amountUsd: number }> { + // `challenge` is the marketplace's 402 body, already parsed: + // { x402Version, accepts:[{ scheme, network, amount, asset, payTo, ... }], resource }. + // extractPaymentDetails reads accepts[0] directly (amount + payTo) — no need + // to round-trip through base64, which would choke on a non-Latin1 skill name + // in the resource description. + const details = extractPaymentDetails(challenge); + const wallet = getOrCreateWallet(); + const header = await createPaymentPayload( + wallet.privateKey as `0x${string}`, + wallet.address, + details.recipient, + details.amount, + details.network || 'eip155:8453', + { + resourceUrl: details.resource?.url || runUrl, + // createPaymentPayload base64s the payload with btoa (Latin1-only), so the + // description must stay ASCII — strip anything else out of the skill name. + resourceDescription: `Run ${skillName}`.replace(/[^\x20-\x7E]/g, ''), + maxTimeoutSeconds: details.maxTimeoutSeconds || 300, + extra: details.extra as { name?: string; version?: string } | undefined, + }, + ); + return { header, amountUsd: Number(details.amount) / 1_000_000 }; +} + +/** + * Hire a skill: POST the input, answer the 402 with a signed payment, retry, + * return the result. Fails closed — the marketplace settles ONLY on a + * successful run, so any non-2xx means the wallet was not charged. + */ +export async function runMarketSkill( + slug: string, + input: string, + opts: { signal?: AbortSignal; timeoutMs?: number; runUrl?: string } = {}, +): Promise { + const runUrl = opts.runUrl || `${MARKET_URL}/api/v1/skills/${encodeURIComponent(slug)}/run`; + const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000), MAX_TIMEOUT_MS); + + const ctrl = new AbortController(); + const onAbort = () => ctrl.abort(); + opts.signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }; + const payload = JSON.stringify({ input }); + + try { + let res = await fetch(runUrl, { method: 'POST', headers, body: payload, signal: ctrl.signal }); + let paidUsd = 0; + + if (res.status === 402) { + const challenge = (await res.json().catch(() => null)) as + | (PaymentRequiredLike & { accepts?: unknown[] }) + | null; + if (!challenge?.accepts?.length) { + return { ok: false, status: 402, paidUsd: 0, txHash: null, error: 'could not read payment requirements from the marketplace' }; + } + let signed: { header: string; amountUsd: number }; + try { + signed = await signMarketPayment(challenge, runUrl, slug); + } catch (err) { + return { ok: false, status: 402, paidUsd: 0, txHash: null, error: `payment signing failed: ${(err as Error).message}` }; + } + paidUsd = signed.amountUsd; + res = await fetch(runUrl, { + method: 'POST', + headers: { ...headers, 'x-payment': signed.header }, + body: payload, + signal: ctrl.signal, + }); + } + + const txHash = res.headers.get('x-payment-receipt'); + const body = (await res.json().catch(() => ({}))) as Record; + + if (!res.ok) { + const detail = typeof body.error === 'string' ? body.error : `HTTP ${res.status}`; + // No charge on a non-2xx: the route fails closed before settle. + return { ok: false, status: res.status, paidUsd: 0, txHash, error: detail }; + } + + const result = typeof body.result === 'string' ? body.result : ''; + return { ok: true, status: res.status, result, paidUsd, txHash }; + } catch (err) { + return { ok: false, status: 0, paidUsd: 0, txHash: null, error: (err as Error).message || 'request failed' }; + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', onAbort); + } +} + +// ─── Formatting (shared by the /market command + agent_talent tool) ───────── + +export function fmtUsd(n: number): string { + if (!Number.isFinite(n) || n <= 0) return '$0.00'; + return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`; +} + +function typeBadge(s: MarketSkill): string { + if (s.execution_type === 'agent' && s.data_sources?.length) return `live:${s.data_sources.join(',')}`; + return s.execution_type === 'agent' ? 'live' : 'prompt'; +} + +// Truncate to a column width at a word boundary, with an ellipsis when cut — +// so a row never shows a half-word like "across cha". +function truncate(text: string, max: number): string { + const t = (text || '').replace(/\s+/g, ' ').trim(); + if (t.length <= max) return t; + const cut = t.slice(0, max - 1).replace(/\s+$/, ''); + const sp = cut.lastIndexOf(' '); + const base = sp >= Math.floor(max * 0.6) ? cut.slice(0, sp) : cut; // word boundary, unless that loses too much + return `${base}…`; +} + +/** A compact numbered list for the terminal (one row per skill). */ +export function formatCatalogList(skills: MarketSkill[], opts: { heading?: string } = {}): string { + if (skills.length === 0) return 'No matching skills in the marketplace.\n'; + const lines = skills.map((s, i) => { + const n = String(i + 1).padStart(2, ' '); + // Never truncate the slug — it's the identifier the user types into + // `/market run `; pad short ones, let a rare long one overflow. + const slug = s.slug.padEnd(18); + const price = fmtUsd(s.price_usd).padStart(7); + const desc = truncate(s.description || '', 40).padEnd(40); + const by = s.creator?.x ? ` @${s.creator.x}` : ''; + return ` ${n}. ${slug} ${price} ${desc}${by}`.replace(/\s+$/, ''); + }); + const head = opts.heading ? `${opts.heading}\n` : ''; + const foot = '\n > /market to search . /market info . /market run \n'; + return `${head}${lines.join('\n')}\n${foot}`; +} + +/** A fuller detail card for a single skill (shown by `/market info`). */ +export function formatSkillCard(s: MarketSkill): string { + const by = s.creator?.x ? ` . by @${s.creator.x}` : ''; + const sample = s.sample_input + ? ` e.g. ${JSON.stringify(s.sample_input)}${s.sample_output ? ` -> ${JSON.stringify(s.sample_output)}` : ''}\n` + : ''; + return ( + ` ${s.name} . ${fmtUsd(s.price_usd)}/run . ${s.backing_model}\n` + + ` ${s.description}\n` + + ` [${typeBadge(s)}]${by}\n` + + sample + + ` > /market run ${s.slug} \n` + ); +} diff --git a/src/tools/agent-talent.ts b/src/tools/agent-talent.ts new file mode 100644 index 0000000..23e79c6 --- /dev/null +++ b/src/tools/agent-talent.ts @@ -0,0 +1,132 @@ +/** + * agent_talent — hire paid AI skills from the BlockRun agent marketplace. + * + * The autonomous counterpart to the `/market` slash command: where `/market` + * lets a human browse and hire, this capability lets the agent discover and + * hire talent mid-task. `action: "list"` returns the catalog (free GET); + * `action: "run"` hires a skill by slug, signing ONE standard `exact` x402 + * USDC payment from the user wallet on Base (only on a successful run), and + * returns the skill's output plus the USD paid. + * + * Both actions delegate to src/market/client.ts, the single payment path + * shared with the command — see that file for the x402 details. + */ + +import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; +import { fetchCatalog, runMarketSkill, fmtUsd, type MarketSkill } from '../market/client.js'; +import { recordUsage } from '../stats/tracker.js'; +import { logger } from '../logger.js'; + +interface AgentTalentInput { + action?: string; + query?: string; + slug?: string; + input?: string; + limit?: number; +} + +// A model-facing line per skill: enough to choose, not a wall of text. +function listLine(s: MarketSkill): string { + const type = s.execution_type === 'agent' && s.data_sources?.length + ? `live-data(${s.data_sources.join(',')})` + : s.execution_type; + const sample = s.sample_input ? ` | e.g. ${JSON.stringify(s.sample_input)}` : ''; + const by = s.creator?.x ? ` | by @${s.creator.x}` : ''; + return `- ${s.slug} — ${s.name} [${type}] ${fmtUsd(s.price_usd)}/run${by}\n ${s.description}${sample}`; +} + +export const agentTalentCapability: CapabilityHandler = { + spec: { + name: 'agent_talent', + description: + 'Browse and hire paid AI skills ("talents") from the BlockRun agent marketplace — specialized agents other creators published. ' + + 'action="list" returns the catalog for free. Use it whenever the user asks you to find, search, browse, or recommend an agent / skill / talent for some domain or task (pass their topic as `query`), OR when you yourself need talent for a sub-task you cannot do well (live market/on-chain data, a domain-specific analysis, a niche transform). ' + + '`query` filters by name, description, and data source; the match is semantic on your part — map the user\'s intent (e.g. "track gas prices" -> "gas", a request in any language) to a sensible keyword. Omit `query` to list the most popular. ' + + 'action="run" hires one skill by `slug` with your `input`: it signs ONE standard USDC x402 payment from the user wallet on Base and returns the skill\'s output. ' + + 'The wallet is charged automatically and ONLY on a successful run (the response reports the USD paid); a failed run is free. ' + + 'Prefer listing first to get the exact slug and price before running.', + input_schema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['list', 'run'], + description: '"list" to browse/search the catalog (free); "run" to hire a skill (paid).', + }, + query: { + type: 'string', + description: 'For list: optional keyword filter over name, description, and data sources.', + }, + slug: { + type: 'string', + description: 'For run: the skill slug to hire (get it from list).', + }, + input: { + type: 'string', + description: 'For run: the input text to send the skill.', + }, + limit: { + type: 'number', + description: 'For list: max skills to return (default 20, max 200).', + }, + }, + required: ['action'], + }, + }, + // list is a free read; run signs a payment. Only the read is parallel-safe. + isConcurrentSafe: (input) => (input as AgentTalentInput).action === 'list', + + async execute(input: Record, ctx: ExecutionScope): Promise { + const raw = input as AgentTalentInput; + const action = typeof raw.action === 'string' ? raw.action.toLowerCase() : ''; + + if (action === 'list') { + try { + const limit = Math.min(Math.max(typeof raw.limit === 'number' ? raw.limit : 20, 1), 200); + const skills = await fetchCatalog({ + limit, + query: typeof raw.query === 'string' ? raw.query : undefined, + signal: ctx.abortSignal, + }); + if (skills.length === 0) { + return { output: raw.query ? `No marketplace skills match "${raw.query}".` : 'The marketplace has no runnable skills right now.' }; + } + const head = raw.query + ? `BlockRun agent talents — ${skills.length} skill(s) matching "${raw.query}":` + : `BlockRun agent talents — ${skills.length} skill(s):`; + const body = skills.map(listLine).join('\n'); + const out = `${head}\n${body}\n\nTo hire one: agent_talent { action: "run", slug: "", input: "" }.`; + return { output: out, fullOutput: out }; + } catch (err) { + return { output: `Could not reach the marketplace: ${(err as Error).message}`, isError: true }; + } + } + + if (action === 'run') { + const slug = typeof raw.slug === 'string' ? raw.slug.trim() : ''; + const userInput = typeof raw.input === 'string' ? raw.input : ''; + if (!slug) return { output: 'Error: `slug` is required for action="run" (list first to find it).', isError: true }; + if (!userInput.trim()) return { output: 'Error: `input` is required for action="run".', isError: true }; + + const outcome = await runMarketSkill(slug, userInput, { signal: ctx.abortSignal }); + + try { + recordUsage(`agent_talent:${slug}`, 0, 0, outcome.paidUsd, 0); + } catch { /* best-effort telemetry */ } + + if (!outcome.ok) { + logger.warn(`[franklin] agent_talent run ${slug} failed (${outcome.status}): ${outcome.error}`); + return { + output: `Hiring "${slug}" failed: ${outcome.error ?? `HTTP ${outcome.status}`}. No charge (the marketplace settles only on success).`, + isError: true, + }; + } + + const receipt = `Hired ${slug} — paid ${fmtUsd(outcome.paidUsd)}${outcome.txHash ? ` (tx ${outcome.txHash.slice(0, 10)}…)` : ''}`; + const out = `${receipt}\n\n${outcome.result ?? ''}`; + return { output: out, fullOutput: out }; + } + + return { output: `Error: unknown action "${raw.action}". Use "list" or "run".`, isError: true }; + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index eb48d4a..c46ae36 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -44,6 +44,7 @@ import { multiChainRpcCapability } from './rpc.js'; import { predictionMarketCapability } from './prediction.js'; import { modalCapabilities } from './modal.js'; import { blockrunCapability } from './blockrun.js'; +import { agentTalentCapability } from './agent-talent.js'; import { surfCapabilities } from './surf.js'; import { realFaceCapability } from './realface.js'; import { @@ -200,6 +201,7 @@ export const allCapabilities: CapabilityHandler[] = [ multiChainRpcCapability, // read-only JSON-RPC across 40+ chains ($0.002/call) predictionMarketCapability, // Polymarket / Kalshi / matching / smart money via Predexon blockrunCapability, // Generic x402-paid gateway primitive — future partners + long-tail Surf paths + agentTalentCapability, // Hire paid skills from the BlockRun agent marketplace (business.blockrun.ai) ...surfCapabilities, // SurfMarket / SurfChain / SurfSocial — endpoint-enum function tools (no path guessing, auto x402) // Phone & Voice — typed surface so the agent pattern-matches on the user // intent ("buy a number", "make a call") without needing to consult the diff --git a/test/market.local.mjs b/test/market.local.mjs new file mode 100644 index 0000000..a84be9b --- /dev/null +++ b/test/market.local.mjs @@ -0,0 +1,340 @@ +/** + * Deterministic tests for the BlockRun agent-market client (src/market/client.ts), + * the shared engine behind the `/market` command and the agent_talent tool. + * + * A mock marketplace stands in for business.blockrun.ai: it serves the public + * catalog and answers a run POST with a standard x402 402 challenge, then 200 + * on the paid retry. No network, no real wallet — a throwaway key signs the + * payment so we can assert Franklin authorizes the EXACT advertised price (the + * invariant the live route enforces with `signedValueMicro === totalMicro`). + */ + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; + +// Anvil account #1 — a throwaway signing key (address 0x7099…79C8). Set before +// importing the client so getOrCreateWallet() never touches ~/.blockrun. +process.env.BLOCKRUN_WALLET_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const WALLET_ADDR = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + +const PRICE_MICRO = '20000'; // $0.02 +const PAY_TO = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; + +const CATALOG = { + skills: [ + { + slug: 'yield-radar', name: 'Yield Radar', description: 'Live stablecoin yields across chains', + price_usd: 0.02, backing_model: 'anthropic/claude-haiku-4.5', run_count: 7, execution_type: 'agent', + data_sources: ['api.barker.money'], sample_input: 'best yields', sample_output: 'Vectis 4.24%', + creator: { wallet: '0xabc0000000000000000000000000000000000abc', x: 'barker' }, run_url: '', + }, + { + slug: 'summarize', name: 'Summarizer', description: 'Summarize any text', + price_usd: 0.01, backing_model: 'anthropic/claude-haiku-4.5', run_count: 42, execution_type: 'prompt', + data_sources: [], sample_input: 'long text', sample_output: 'short', creator: { wallet: '0xdef', x: null }, run_url: '', + }, + ], +}; + +let server; +let base; +let mod; +let tool; // agent_talent CapabilityHandler +let handleSlashCommand; +let lastLimit; // the ?limit= the discovery GET last received (clamp check) +const paidCalls = []; // records the decoded x-payment on each paid retry + +before(async () => { + server = createServer(async (req, res) => { + const url = req.url || ''; + const json = (code, body, extra) => { + res.writeHead(code, { 'Content-Type': 'application/json', ...(extra || {}) }); + res.end(JSON.stringify(body)); + }; + + if (req.method === 'GET' && url.startsWith('/api/v1/skills')) { + lastLimit = new URL(url, base).searchParams.get('limit'); + return json(200, CATALOG); + } + + const runMatch = url.match(/^\/api\/v1\/skills\/([^/]+)\/run$/); + if (req.method === 'POST' && runMatch) { + const slug = runMatch[1]; + let bodyStr = ''; + for await (const chunk of req) bodyStr += chunk; + const reqBody = JSON.parse(bodyStr || '{}'); + + if (slug === 'always-fail') { + return json(502, { error: 'upstream boom', code: 'UPSTREAM_ERROR' }); + } + + const xpay = req.headers['x-payment']; + if (!xpay) { + // Standard x402 challenge — same body shape the live route returns. + return json(402, { + x402Version: 2, + accepts: [{ + scheme: 'exact', network: 'eip155:8453', amount: PRICE_MICRO, asset: USDC, + payTo: PAY_TO, maxTimeoutSeconds: 300, extra: { name: 'USD Coin', version: '2' }, + }], + resource: { url: `${base}/api/v1/skills/${slug}/run`, description: `Run — ${slug}`, mimeType: 'application/json' }, + }); + } + + const decoded = JSON.parse(Buffer.from(xpay, 'base64').toString()); + paidCalls.push({ + slug, + input: reqBody.input, + value: decoded.payload?.authorization?.value, + from: decoded.payload?.authorization?.from, + to: decoded.payload?.authorization?.to, + }); + return json(200, { result: `ran ${slug} on: ${reqBody.input}` }, { 'X-Payment-Receipt': '0xdeadbeefcafe0000' }); + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${server.address().port}`; + process.env.BLOCKRUN_MARKET_URL = base; + mod = await import('../dist/market/client.js'); + tool = (await import('../dist/tools/agent-talent.js')).agentTalentCapability; + ({ handleSlashCommand } = await import('../dist/agent/commands.js')); +}); + +after(() => server?.close()); + +// Minimal ExecutionScope for the tool. +const toolCtx = { workingDir: process.cwd(), abortSignal: new AbortController().signal }; + +// Drive a slash command and capture the text it emits. +async function runCommand(input) { + let text = ''; + const ctx = { + history: [], sessionId: 't', config: {}, client: {}, + onEvent: (e) => { if (e.kind === 'text_delta') text += e.text; }, + }; + const r = await handleSlashCommand(input, ctx); + return { r, text }; +} + +test('fetchCatalog parses the public catalog', async () => { + const skills = await mod.fetchCatalog(); + assert.equal(skills.length, 2); + const yr = skills.find((s) => s.slug === 'yield-radar'); + assert.equal(yr.price_usd, 0.02); + assert.deepEqual(yr.data_sources, ['api.barker.money']); +}); + +test('fetchCatalog ranks skills by call volume, highest first', async () => { + // The mock returns yield-radar (7 runs) before summarize (42 runs); the + // client must reorder so the most-called skill leads, on every surface. + const skills = await mod.fetchCatalog(); + assert.equal(skills[0].slug, 'summarize'); // 42 > 7 + const counts = skills.map((s) => s.run_count); + assert.deepEqual(counts, [...counts].sort((a, b) => b - a)); // non-increasing +}); + +test('filterCatalog matches on name, description, and data source', async () => { + const skills = await mod.fetchCatalog(); + assert.deepEqual(mod.filterCatalog(skills, 'barker').map((s) => s.slug), ['yield-radar']); + assert.deepEqual(mod.filterCatalog(skills, 'summarize').map((s) => s.slug), ['summarize']); + assert.deepEqual(mod.filterCatalog(skills, 'yields').map((s) => s.slug), ['yield-radar']); + assert.equal(mod.filterCatalog(skills, 'nonexistent-zzz').length, 0); +}); + +test('formatCatalogList renders a numbered row with slug + price', async () => { + const skills = await mod.fetchCatalog(); + const out = mod.formatCatalogList(skills, { heading: 'Marketplace:' }); + assert.match(out, /Marketplace:/); + assert.match(out, /yield-radar/); + assert.match(out, /\$0\.02/); + assert.match(out, /\/market run /); +}); + +test('formatCatalogList truncates long descriptions at a word boundary', () => { + const src = 'Live stablecoin yields ranked across many chains and protocols'; + const out = mod.formatCatalogList([{ + slug: 'x', name: 'X', description: src, price_usd: 0.02, backing_model: 'm', + run_count: 1, execution_type: 'prompt', data_sources: [], creator: { wallet: '0x', x: null }, run_url: '', + }]); + assert.match(out, /…/); // it was truncated + // the word right before the ellipsis must be a COMPLETE source word, not a fragment + const lastWord = out.split('…')[0].trim().split(' ').pop(); + assert.ok(src.split(' ').includes(lastWord), `"${lastWord}" should be a whole word, not a mid-word cut`); +}); + +test('formatCatalogList never truncates the slug (it is the run identifier)', () => { + const slug = 'a-very-long-skill-slug-past-eighteen'; + const out = mod.formatCatalogList([{ + slug, name: 'X', description: 'short', price_usd: 0.01, backing_model: 'm', + run_count: 1, execution_type: 'prompt', data_sources: [], creator: { wallet: '0x', x: null }, run_url: '', + }]); + assert.match(out, new RegExp(slug.replace(/[-]/g, '\\-'))); // full slug present, uncut +}); + +test('runMarketSkill answers the 402 and authorizes the EXACT advertised price', async () => { + paidCalls.length = 0; + const outcome = await mod.runMarketSkill('yield-radar', 'best yields right now'); + + assert.equal(outcome.ok, true); + assert.equal(outcome.status, 200); + assert.equal(outcome.result, 'ran yield-radar on: best yields right now'); + assert.equal(outcome.paidUsd, 0.02); + assert.equal(outcome.txHash, '0xdeadbeefcafe0000'); + + // The signed authorization must carry the exact price the route requires + // (the live route rejects any other value), be paid by our wallet, and pay + // the advertised recipient. + assert.equal(paidCalls.length, 1); + assert.equal(paidCalls[0].value, PRICE_MICRO); + assert.equal(paidCalls[0].from.toLowerCase(), WALLET_ADDR.toLowerCase()); + assert.equal(paidCalls[0].to.toLowerCase(), PAY_TO.toLowerCase()); + assert.equal(paidCalls[0].input, 'best yields right now'); +}); + +test('runMarketSkill fails closed with no charge on a non-2xx run', async () => { + const outcome = await mod.runMarketSkill('always-fail', 'whatever'); + assert.equal(outcome.ok, false); + assert.equal(outcome.status, 502); + assert.equal(outcome.paidUsd, 0); + assert.match(outcome.error, /upstream boom/); +}); + +test('fetchCatalog clamps the limit to 200 and passes it through', async () => { + await mod.fetchCatalog({ limit: 500 }); + assert.equal(lastLimit, '200'); + await mod.fetchCatalog({ limit: 5 }); + assert.equal(lastLimit, '5'); +}); + +test('formatSkillCard shows price, model, type badge, sample and a run hint', async () => { + const skills = await mod.fetchCatalog(); + const card = mod.formatSkillCard(skills.find((s) => s.slug === 'yield-radar')); + assert.match(card, /Yield Radar/); + assert.match(card, /\$0\.02\/run/); + assert.match(card, /anthropic\/claude-haiku-4\.5/); + assert.match(card, /live:api\.barker\.money/); + assert.match(card, /@barker/); + assert.match(card, /\/market run yield-radar/); +}); + +// ─── agent_talent tool ────────────────────────────────────────────────────── + +test('agent_talent list returns the catalog with slug + price + type', async () => { + const r = await tool.execute({ action: 'list' }, toolCtx); + assert.equal(r.isError, undefined); + assert.match(r.output, /yield-radar/); + assert.match(r.output, /\$0\.02\/run/); + assert.match(r.output, /live-data\(api\.barker\.money\)/); + assert.match(r.output, /action: "run"/); +}); + +test('agent_talent list filters by query', async () => { + const r = await tool.execute({ action: 'list', query: 'summarize' }, toolCtx); + assert.match(r.output, /summarize/); + assert.doesNotMatch(r.output, /yield-radar/); +}); + +test('agent_talent list reports cleanly when nothing matches', async () => { + const r = await tool.execute({ action: 'list', query: 'no-such-skill-zzz' }, toolCtx); + assert.match(r.output, /No marketplace skills match/); +}); + +test('agent_talent run hires a skill and reports the amount paid', async () => { + const r = await tool.execute({ action: 'run', slug: 'yield-radar', input: 'best yields' }, toolCtx); + assert.equal(r.isError, undefined); + assert.match(r.output, /Hired yield-radar/); + assert.match(r.output, /paid \$0\.02/); + assert.match(r.output, /ran yield-radar on: best yields/); +}); + +test('agent_talent run requires slug and input', async () => { + const noSlug = await tool.execute({ action: 'run', input: 'x' }, toolCtx); + assert.equal(noSlug.isError, true); + assert.match(noSlug.output, /slug` is required/); + const noInput = await tool.execute({ action: 'run', slug: 'yield-radar' }, toolCtx); + assert.equal(noInput.isError, true); + assert.match(noInput.output, /input` is required/); +}); + +test('agent_talent run surfaces a failed hire as no-charge', async () => { + const r = await tool.execute({ action: 'run', slug: 'always-fail', input: 'x' }, toolCtx); + assert.equal(r.isError, true); + assert.match(r.output, /failed/); + assert.match(r.output, /No charge/); +}); + +test('agent_talent rejects an unknown action', async () => { + const r = await tool.execute({ action: 'frobnicate' }, toolCtx); + assert.equal(r.isError, true); + assert.match(r.output, /unknown action/); +}); + +test('agent_talent marks only list (not run) concurrency-safe', () => { + assert.equal(tool.isConcurrentSafe({ action: 'list' }), true); + assert.equal(tool.isConcurrentSafe({ action: 'run' }), false); +}); + +// ─── /market slash command ────────────────────────────────────────────────── + +test('/market browses the catalog', async () => { + const { r, text } = await runCommand('/market'); + assert.equal(r.handled, true); + assert.match(text, /Agent talents/); + assert.match(text, /yield-radar/); + assert.match(text, /summarize/); +}); + +test('/market searches', async () => { + const { text } = await runCommand('/market summarize'); + assert.match(text, /matching "summarize"/); + assert.match(text, /summarize/); + assert.doesNotMatch(text, /yield-radar/); +}); + +test('/market info shows the detail card', async () => { + const { text } = await runCommand('/market info yield-radar'); + assert.match(text, /Yield Radar/); + assert.match(text, /\$0\.02\/run/); + assert.match(text, /live:api\.barker\.money/); +}); + +test('/market info without a slug prints usage', async () => { + const { text } = await runCommand('/market info'); + assert.match(text, /Usage: \/market info /); +}); + +test('/market run pays and prints the result', async () => { + const { text } = await runCommand('/market run yield-radar best yields now'); + assert.match(text, /Paid \$0\.02/); + assert.match(text, /ran yield-radar on: best yields now/); +}); + +test('/market run with bad args prints usage', async () => { + const { text } = await runCommand('/market run'); + assert.match(text, /Usage: \/market run /); +}); + +test('/market run on a failing skill reports no charge', async () => { + const { text } = await runCommand('/market run always-fail something'); + assert.match(text, /Could not run always-fail/); + assert.match(text, /No charge/); +}); + +// ─── permission policy: free to browse, asks before it spends ─────────────── + +test('agent_talent browsing auto-allows but hiring asks for confirmation', async () => { + const { PermissionManager } = await import('../dist/agent/permissions.js'); + const pm = new PermissionManager('default'); + + const list = await pm.check('agent_talent', { action: 'list', query: 'yields' }); + assert.equal(list.behavior, 'allow'); // free read — no prompt + + const run = await pm.check('agent_talent', { action: 'run', slug: 'yield-radar', input: 'x' }); + assert.equal(run.behavior, 'ask'); // spends USDC — must confirm +});