/** * Tool definitions (JSON Schema) + implementations. * * The SAME schema objects feed both execution paths: * - native mode -> sent verbatim as the OpenAI-style `tools` array * - prompted mode -> JSON.stringify'd into the system prompt * One source of truth, so the two modes can never drift apart. * * Every implementation returns a SMALL, hand-picked object -- never a raw API * payload. The target models are 2-7B, and a 169-point price series or a 2 kB * description would swamp their context. */ const CG = "https://api.coingecko.com/api/v3"; const FX = "https://open.er-api.com/v6/latest"; export class ToolError extends Error {} /* ------------------------------------------------------------------ * * fetch helper: 60s TTL cache, typed failures * ------------------------------------------------------------------ */ const cache = new Map(); const TTL_MS = 60_000; async function getJSON(url) { const hit = cache.get(url); if (hit && Date.now() - hit.at < TTL_MS) return hit.data; let res; try { res = await fetch(url, { headers: { accept: "application/json" } }); } catch (e) { // A network/CORS failure must come back as data, not an exception, so the // model can narrate it instead of the agent loop dying. throw new ToolError(`network error contacting ${new URL(url).host}: ${e.message}`); } if (res.status === 429) { throw new ToolError("rate limited by CoinGecko (free tier). Wait ~60s and retry."); } if (!res.ok) throw new ToolError(`upstream returned HTTP ${res.status}`); const data = await res.json(); cache.set(url, { at: Date.now(), data }); return data; } const round = (n, dp = 2) => typeof n === "number" && isFinite(n) ? Number(n.toFixed(dp)) : null; /* ------------------------------------------------------------------ * * JSON Schema definitions * ------------------------------------------------------------------ */ export const TOOL_SCHEMAS = [ { type: "function", function: { name: "search_coin", description: "Resolve a coin name or ticker symbol to its CoinGecko id. Call this FIRST whenever the user names a coin in words (e.g. 'Bitcoin', 'Solana') and you do not already know its id.", parameters: { type: "object", properties: { query: { type: "string", description: "Coin name or symbol, e.g. 'bitcoin', 'ETH', 'solana'.", }, }, required: ["query"], }, }, }, { type: "function", function: { name: "get_price", description: "Get the current price, 24-hour change and market cap of a coin. Requires a CoinGecko id (use search_coin first if unsure).", parameters: { type: "object", properties: { coin_id: { type: "string", description: "CoinGecko id, e.g. 'bitcoin' (NOT the symbol 'BTC').", }, vs_currency: { type: "string", description: "Quote currency code, lowercase. Defaults to 'usd'.", default: "usd", }, }, required: ["coin_id"], }, }, }, { type: "function", function: { name: "get_market_chart", description: "Get summary statistics of a coin's price over the last N days: open, close, low, high and percent change. Use for trend or 'how has X done' questions.", parameters: { type: "object", properties: { coin_id: { type: "string", description: "CoinGecko id, e.g. 'ethereum'." }, days: { type: "integer", description: "Look-back window in days (1-365). Defaults to 7.", default: 7, }, vs_currency: { type: "string", description: "Quote currency code, lowercase. Defaults to 'usd'.", default: "usd", }, }, required: ["coin_id"], }, }, }, { type: "function", function: { name: "get_coin_info", description: "Get descriptive facts about a coin: full name, symbol, market-cap rank, launch date, categories, all-time high and a short description. Use for 'what is X' questions, not for prices.", parameters: { type: "object", properties: { coin_id: { type: "string", description: "CoinGecko id, e.g. 'cardano'." }, }, required: ["coin_id"], }, }, }, { type: "function", function: { name: "convert_currency", description: "Convert an amount of fiat money from one currency to another using live exchange rates. Use this to express a price in another currency, e.g. USD to TRY.", parameters: { type: "object", properties: { amount: { type: "number", description: "The amount to convert." }, from_currency: { type: "string", description: "Source ISO 4217 code, e.g. 'USD'." }, to_currency: { type: "string", description: "Target ISO 4217 code, e.g. 'TRY'." }, }, required: ["amount", "from_currency", "to_currency"], }, }, }, ]; /* ------------------------------------------------------------------ * * Implementations * ------------------------------------------------------------------ */ export const TOOL_IMPLS = { async search_coin({ query }) { if (!query) throw new ToolError("missing required argument 'query'"); const d = await getJSON(`${CG}/search?query=${encodeURIComponent(query)}`); const coins = (d.coins || []).slice(0, 4).map((c) => ({ id: c.id, symbol: (c.symbol || "").toUpperCase(), name: c.name, market_cap_rank: c.market_cap_rank ?? null, })); if (!coins.length) throw new ToolError(`no coin found matching '${query}'`); return { query, matches: coins, best_match_id: coins[0].id }; }, async get_price({ coin_id, vs_currency = "usd" }) { if (!coin_id) throw new ToolError("missing required argument 'coin_id'"); const vs = String(vs_currency).toLowerCase(); const id = String(coin_id).toLowerCase(); const d = await getJSON( `${CG}/simple/price?ids=${encodeURIComponent(id)}&vs_currencies=${encodeURIComponent(vs)}` + `&include_24hr_change=true&include_market_cap=true` ); const row = d[id]; if (!row || row[vs] === undefined) { throw new ToolError( `no price for id '${coin_id}' in '${vs}'. Use search_coin to get the correct id.` ); } return { coin_id: id, vs_currency: vs.toUpperCase(), price: round(row[vs], 6), change_24h_pct: round(row[`${vs}_24h_change`]), market_cap: round(row[`${vs}_market_cap`], 0), }; }, async get_market_chart({ coin_id, days = 7, vs_currency = "usd" }) { if (!coin_id) throw new ToolError("missing required argument 'coin_id'"); const vs = String(vs_currency).toLowerCase(); const id = String(coin_id).toLowerCase(); const n = Math.min(Math.max(parseInt(days, 10) || 7, 1), 365); const d = await getJSON( `${CG}/coins/${encodeURIComponent(id)}/market_chart?vs_currency=${vs}&days=${n}` ); const prices = (d.prices || []).map((p) => p[1]).filter((v) => isFinite(v)); if (!prices.length) throw new ToolError(`no price history for '${coin_id}'`); const open = prices[0]; const close = prices[prices.length - 1]; return { coin_id: id, vs_currency: vs.toUpperCase(), days: n, open: round(open, 6), close: round(close, 6), low: round(Math.min(...prices), 6), high: round(Math.max(...prices), 6), change_pct: round(((close - open) / open) * 100), data_points: prices.length, }; }, async get_coin_info({ coin_id }) { if (!coin_id) throw new ToolError("missing required argument 'coin_id'"); const id = String(coin_id).toLowerCase(); const d = await getJSON( `${CG}/coins/${encodeURIComponent(id)}` + `?localization=false&tickers=false&market_data=true` + `&community_data=false&developer_data=false` ); const desc = (d.description?.en || "").replace(/<[^>]*>/g, "").trim(); return { coin_id: d.id, name: d.name, symbol: (d.symbol || "").toUpperCase(), market_cap_rank: d.market_cap_rank ?? null, genesis_date: d.genesis_date ?? null, categories: (d.categories || []).filter(Boolean).slice(0, 3), all_time_high_usd: round(d.market_data?.ath?.usd, 2), // Truncated hard: full descriptions run ~2 kB and crowd out a 2B context. description: desc.length > 280 ? desc.slice(0, 280) + "..." : desc, }; }, async convert_currency({ amount, from_currency, to_currency }) { const amt = Number(amount); if (!isFinite(amt)) throw new ToolError("'amount' must be a number"); if (!from_currency || !to_currency) { throw new ToolError("both 'from_currency' and 'to_currency' are required"); } const from = String(from_currency).toUpperCase(); const to = String(to_currency).toUpperCase(); if (from === to) { return { amount: amt, from_currency: from, to_currency: to, rate: 1, result: round(amt) }; } const d = await getJSON(`${FX}/${encodeURIComponent(from)}`); if (d.result === "error" || !d.rates) { throw new ToolError(`unknown source currency '${from}'`); } const rate = d.rates[to]; if (rate === undefined) throw new ToolError(`unknown target currency '${to}'`); return { amount: amt, from_currency: from, to_currency: to, rate: round(rate, 6), result: round(amt * rate), rate_date: d.time_last_update_utc || null, }; }, }; /** * Execute a tool by name. Never throws: a failure is returned as * `{ error: "..." }` so the model sees it as an ordinary observation and can * recover (e.g. retry with a corrected id) or explain it to the user. */ export async function runTool(name, args) { const impl = TOOL_IMPLS[name]; if (!impl) { return { error: `unknown tool '${name}'. Available: ${Object.keys(TOOL_IMPLS).join(", ")}` }; } try { return await impl(args || {}); } catch (e) { return { error: e instanceof ToolError ? e.message : `unexpected failure: ${e.message}` }; } }