ErenYanic's picture
Tool-calling agent for models without native function calling
9eeee48 verified
Raw
History Blame Contribute Delete
10.3 kB
/**
* 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}` };
}
}