diff --git a/agent/market-agent.tsx b/agent/market-agent.tsx index 6752e3c..2152601 100644 --- a/agent/market-agent.tsx +++ b/agent/market-agent.tsx @@ -1,4 +1,6 @@ import { marketTool } from "@/tool/market-tool"; +import { compareTokensTool } from "@/tool/compare-tool"; +import { priceHistoryTool } from "@/tool/price-history-tool"; import { openai } from "@ai-sdk/openai"; import { ToolLoopAgent, InferAgentUIMessage } from "ai"; @@ -7,6 +9,8 @@ export const marketAgent = new ToolLoopAgent({ instructions: "You are a helpful assistant that can help with market data.", tools: { market: marketTool, + compare: compareTokensTool, + priceHistory: priceHistoryTool, }, }); diff --git a/tool/compare-tool.ts b/tool/compare-tool.ts new file mode 100644 index 0000000..9d8014f --- /dev/null +++ b/tool/compare-tool.ts @@ -0,0 +1,104 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; + +async function fetchJson(url: string, timeoutMs = 10_000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) { + if (res.status === 429) { + throw new Error("CoinGecko rate limit reached. Please wait a moment before retrying."); + } + throw new Error(`Request failed (${res.status} ${res.statusText})`); + } + return (await res.json()) as T; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`Request timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timeout); + } +} + +async function resolveSymbolToId(symbol: string): Promise<{ id: string; name: string; symbol: string; market_cap_rank: number | null }> { + const url = `${COINGECKO_BASE_URL}/search?query=${encodeURIComponent(symbol)}`; + const search = await fetchJson<{ + coins: Array<{ id: string; name: string; symbol: string; market_cap_rank: number | null }>; + }>(url); + + const coins = search?.coins ?? []; + const exact = coins.filter((c) => c.symbol?.toLowerCase() === symbol.toLowerCase()); + const candidates = exact.length > 0 ? exact : coins; + const best = candidates.sort((a, b) => { + const ar = a.market_cap_rank ?? Number.POSITIVE_INFINITY; + const br = b.market_cap_rank ?? Number.POSITIVE_INFINITY; + return ar - br; + })[0]; + + if (!best) throw new Error(`Could not resolve "${symbol}" to a known token.`); + return best; +} + +export const compareTokensTool = tool({ + description: "Compare market data for multiple crypto tokens side by side", + inputSchema: z.object({ + tokens: z + .array(z.string().min(1)) + .min(2) + .max(5) + .describe("List of token symbols to compare (e.g. ['BTC', 'ETH', 'SOL'])"), + vsCurrency: z + .string() + .min(2) + .default("usd") + .describe("Quote currency (e.g. usd, eur)"), + }), + async *execute(input: { tokens: string[]; vsCurrency: string }) { + yield { state: "loading" as const }; + + const vsCurrency = input.vsCurrency.trim().toLowerCase(); + + // Resolve all symbols to CoinGecko IDs in parallel + const resolved = await Promise.all( + input.tokens.map((t) => resolveSymbolToId(t.trim())) + ); + + const ids = resolved.map((r) => r.id).join(","); + const url = + `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent(ids)}` + + `&vs_currencies=${encodeURIComponent(vsCurrency)}` + + `&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true&include_last_updated_at=true`; + + const prices = await fetchJson< + Record> + >(url); + + const comparison = resolved.map((coin) => ({ + id: coin.id, + symbol: coin.symbol, + name: coin.name, + marketCapRank: coin.market_cap_rank, + price: prices[coin.id]?.[vsCurrency], + marketCap: prices[coin.id]?.[`${vsCurrency}_market_cap`], + volume24h: prices[coin.id]?.[`${vsCurrency}_24h_vol`], + change24h: prices[coin.id]?.[`${vsCurrency}_24h_change`], + lastUpdatedAt: prices[coin.id]?.last_updated_at, + })); + + yield { + state: "ready" as const, + comparisonData: JSON.stringify({ provider: "coingecko", vsCurrency, tokens: comparison }), + }; + }, +}); + +export type CompareUIToolInvocation = ReturnType; diff --git a/tool/price-history-tool.ts b/tool/price-history-tool.ts new file mode 100644 index 0000000..0f84d8f --- /dev/null +++ b/tool/price-history-tool.ts @@ -0,0 +1,118 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; + +async function fetchJson(url: string, timeoutMs = 15_000): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) { + if (res.status === 429) { + throw new Error("CoinGecko rate limit reached. Please wait a moment before retrying."); + } + throw new Error(`Request failed (${res.status} ${res.statusText})`); + } + return (await res.json()) as T; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`Request timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timeout); + } +} + +async function resolveSymbolToId(symbol: string): Promise { + const url = `${COINGECKO_BASE_URL}/search?query=${encodeURIComponent(symbol)}`; + const search = await fetchJson<{ + coins: Array<{ id: string; symbol: string; market_cap_rank: number | null }>; + }>(url); + + const coins = search?.coins ?? []; + const exact = coins.filter((c) => c.symbol?.toLowerCase() === symbol.toLowerCase()); + const candidates = exact.length > 0 ? exact : coins; + const best = candidates.sort((a, b) => { + const ar = a.market_cap_rank ?? Number.POSITIVE_INFINITY; + const br = b.market_cap_rank ?? Number.POSITIVE_INFINITY; + return ar - br; + })[0]; + + if (!best) throw new Error(`Could not resolve "${symbol}" to a known token.`); + return best.id; +} + +export const priceHistoryTool = tool({ + description: "Get historical price data for a crypto token over a given time range", + inputSchema: z.object({ + token: z.string().min(1).describe("Token symbol (e.g. BTC, ETH) or CoinGecko coin id"), + days: z + .number() + .int() + .min(1) + .max(365) + .default(7) + .describe("Number of days of historical data to fetch (1-365)"), + vsCurrency: z + .string() + .min(2) + .default("usd") + .describe("Quote currency (e.g. usd, eur)"), + }), + async *execute(input: { token: string; days: number; vsCurrency: string }) { + yield { state: "loading" as const }; + + const vsCurrency = input.vsCurrency.trim().toLowerCase(); + const coinId = await resolveSymbolToId(input.token.trim()); + + const url = + `${COINGECKO_BASE_URL}/coins/${encodeURIComponent(coinId)}/market_chart` + + `?vs_currency=${encodeURIComponent(vsCurrency)}&days=${input.days}`; + + const data = await fetchJson<{ + prices: [number, number][]; + market_caps: [number, number][]; + total_volumes: [number, number][]; + }>(url); + + const prices = data.prices ?? []; + if (prices.length === 0) { + throw new Error(`No historical data found for "${input.token}".`); + } + + const first = prices[0]; + const last = prices[prices.length - 1]; + const high = Math.max(...prices.map(([, p]) => p)); + const low = Math.min(...prices.map(([, p]) => p)); + const change = first[1] > 0 ? ((last[1] - first[1]) / first[1]) * 100 : 0; + + const summary = { + provider: "coingecko", + coinId, + token: input.token, + vsCurrency, + days: input.days, + dataPoints: prices.length, + startPrice: first[1], + endPrice: last[1], + high, + low, + changePercent: change, + startTime: new Date(first[0]).toISOString(), + endTime: new Date(last[0]).toISOString(), + // Sample every ~10th point to keep payload manageable + sparkline: prices.filter((_, i) => i % Math.max(1, Math.floor(prices.length / 30)) === 0).map(([ts, p]) => ({ t: ts, p })), + }; + + yield { + state: "ready" as const, + historyData: JSON.stringify(summary), + }; + }, +});