| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| export const CALL_OPEN = "<tool_call>"; |
| export const CALL_CLOSE = "</tool_call>"; |
|
|
| |
| |
| export const STOP = [CALL_CLOSE]; |
|
|
| export function buildSystemPrompt(schemas) { |
| const fns = schemas.map((s) => s.function); |
| return `You are a financial assistant with access to live market data tools. |
| |
| You cannot know prices from memory. You MUST use the tools for any price, |
| market-cap, history or currency question. |
| |
| AVAILABLE TOOLS (JSON Schema): |
| ${JSON.stringify(fns, null, 2)} |
| |
| HOW TO CALL A TOOL |
| Your reply must BEGIN immediately with a tool call, in exactly this form: |
| ${CALL_OPEN}{"name": "<tool_name>", "arguments": {<args>}}${CALL_CLOSE} |
| |
| RULES |
| 1. Write NO text before the ${CALL_OPEN} tag. No preamble, no explanation, no |
| restating of the question. The tag is the first thing in your reply. |
| 2. NEVER write a tool result yourself. Stop after the closing tag and wait. |
| 3. The result arrives as a message beginning with TOOL_RESULT. |
| 4. To call several tools at once, emit several ${CALL_OPEN} blocks back to back. |
| 5. Use ONLY the tool names listed above, with exactly those argument names. |
| 6. Once the TOOL_RESULT messages give you everything you need, reply in plain |
| prose with NO tool call, and state the actual numbers. |
| 7. If a TOOL_RESULT contains "error", explain the problem or retry with |
| corrected arguments.`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildFewShot() { |
| return [ |
| { role: "user", content: "What is Bitcoin worth?" }, |
| { |
| role: "assistant", |
| content: `${CALL_OPEN}{"name": "get_price", "arguments": {"coin_id": "bitcoin"}}${CALL_CLOSE}`, |
| }, |
| { |
| role: "user", |
| content: |
| 'TOOL_RESULT get_price {"coin_id": "bitcoin", "vs_currency": "USD", "price": 64779, "change_24h_pct": 0.79}', |
| }, |
| { |
| role: "assistant", |
| content: "Bitcoin is trading at $64,779, up 0.79% over the last 24 hours.", |
| }, |
| { role: "user", content: "Is Ethereum pricier than Solana? Show Ethereum in euros too." }, |
| { |
| role: "assistant", |
| content: |
| `${CALL_OPEN}{"name": "get_price", "arguments": {"coin_id": "ethereum"}}${CALL_CLOSE}\n` + |
| `${CALL_OPEN}{"name": "get_price", "arguments": {"coin_id": "solana"}}${CALL_CLOSE}`, |
| }, |
| { |
| role: "user", |
| content: |
| 'TOOL_RESULT get_price {"coin_id": "ethereum", "price": 1919.55, "vs_currency": "USD"}\n' + |
| 'TOOL_RESULT get_price {"coin_id": "solana", "price": 138.2, "vs_currency": "USD"}', |
| }, |
| { |
| role: "assistant", |
| content: `${CALL_OPEN}{"name": "convert_currency", "arguments": {"amount": 1919.55, "from_currency": "USD", "to_currency": "EUR"}}${CALL_CLOSE}`, |
| }, |
| { |
| role: "user", |
| content: 'TOOL_RESULT convert_currency {"result": 1775.58, "to_currency": "EUR", "rate": 0.925}', |
| }, |
| { |
| role: "assistant", |
| content: |
| "Ethereum ($1,919.55) is far pricier than Solana ($138.20). In euros, Ethereum is about €1,775.58.", |
| }, |
| ]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function formatToolResult(name, result) { |
| return `TOOL_RESULT ${name} ${JSON.stringify(result)}`; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function splitReasoning(raw) { |
| let text = raw || ""; |
| let reasoning = ""; |
|
|
| const paired = text.match(/<think>([\s\S]*?)<\/think>/g); |
| if (paired) { |
| for (const block of paired) { |
| reasoning += block.replace(/<\/?think>/g, ""); |
| } |
| text = text.replace(/<think>[\s\S]*?<\/think>/g, ""); |
| } |
|
|
| |
| const orphan = text.indexOf("</think>"); |
| if (orphan !== -1) { |
| reasoning += text.slice(0, orphan); |
| text = text.slice(orphan + "</think>".length); |
| } |
|
|
| |
| const openOnly = text.indexOf("<think>"); |
| if (openOnly !== -1) { |
| reasoning += text.slice(openOnly + "<think>".length); |
| text = text.slice(0, openOnly); |
| } |
|
|
| return { reasoning: reasoning.trim(), text: text.trim() }; |
| } |
|
|
| |
| |
| |
|
|
| |
| function repairJSON(s) { |
| let t = s.trim(); |
| t = t.replace(/,\s*([}\]])/g, "$1"); |
| t = t.replace(/([{,]\s*)'([^']+?)'(\s*:)/g, '$1"$2"$3'); |
| t = t.replace(/(:\s*)'([^']*?)'/g, '$1"$2"'); |
| t = t.replace(/([{,]\s*)([A-Za-z_]\w*)(\s*:)/g, '$1"$2"$3'); |
| t = t.replace(/\bNone\b/g, "null").replace(/\bTrue\b/g, "true").replace(/\bFalse\b/g, "false"); |
| return t; |
| } |
|
|
| function tryParse(s) { |
| try { |
| return JSON.parse(s); |
| } catch { |
| try { |
| return JSON.parse(repairJSON(s)); |
| } catch { |
| return undefined; |
| } |
| } |
| } |
|
|
| |
| function balancedObjects(s) { |
| const out = []; |
| let depth = 0, |
| start = -1, |
| inStr = false, |
| esc = false; |
| for (let i = 0; i < s.length; i++) { |
| const c = s[i]; |
| if (inStr) { |
| if (esc) esc = false; |
| else if (c === "\\") esc = true; |
| else if (c === '"') inStr = false; |
| continue; |
| } |
| if (c === '"') inStr = true; |
| else if (c === "{") { |
| if (depth === 0) start = i; |
| depth++; |
| } else if (c === "}") { |
| depth--; |
| if (depth === 0 && start !== -1) { |
| out.push(s.slice(start, i + 1)); |
| start = -1; |
| } |
| } |
| } |
| return out; |
| } |
|
|
| |
| function normaliseCall(obj) { |
| if (!obj || typeof obj !== "object") return null; |
|
|
| |
| if (obj.tool_call && typeof obj.tool_call === "object") obj = obj.tool_call; |
| if (obj.function && typeof obj.function === "object" && !obj.name) obj = obj.function; |
|
|
| const name = obj.name || obj.tool || obj.tool_name || obj.function_name; |
| if (typeof name !== "string" || !name) return null; |
|
|
| let args = obj.arguments ?? obj.args ?? obj.parameters ?? obj.params ?? obj.input ?? {}; |
| if (typeof args === "string") args = tryParse(args) ?? {}; |
| if (typeof args !== "object" || args === null || Array.isArray(args)) args = {}; |
|
|
| return { name, arguments: args }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function coerceArgs(name, args, schemas) { |
| const schema = schemas.find((s) => s.function.name === name); |
| if (!schema) return args; |
| const props = schema.function.parameters?.properties || {}; |
| const out = {}; |
| for (const [k, v] of Object.entries(args)) { |
| const want = props[k]?.type; |
| if (want === "number" && typeof v === "string" && v.trim() !== "" && isFinite(Number(v))) { |
| out[k] = Number(v); |
| } else if (want === "integer" && typeof v === "string" && /^-?\d+$/.test(v.trim())) { |
| out[k] = parseInt(v, 10); |
| } else if (want === "string" && typeof v === "number") { |
| out[k] = String(v); |
| } else { |
| out[k] = v; |
| } |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function parseToolCalls(raw, schemas = []) { |
| const { reasoning, text } = splitReasoning(raw); |
|
|
| const candidates = []; |
| let consumed = text; |
|
|
| |
| |
| const tagged = /<tool_call>\s*([\s\S]*?)(?:<\/tool_call>|$)/g; |
| let m; |
| while ((m = tagged.exec(text)) !== null) { |
| candidates.push(m[1]); |
| consumed = consumed.replace(m[0], " "); |
| } |
|
|
| |
| const fenced = /```(?:json|tool_call)?\s*([\s\S]*?)```/g; |
| while ((m = fenced.exec(text)) !== null) { |
| candidates.push(m[1]); |
| consumed = consumed.replace(m[0], " "); |
| } |
|
|
| |
| for (const obj of balancedObjects(consumed)) { |
| if (/"?(name|tool|function_name)"?\s*:/.test(obj)) candidates.push(obj); |
| } |
|
|
| const calls = []; |
| const seen = new Set(); |
| for (const cand of candidates) { |
| |
| const pieces = balancedObjects(cand); |
| const chunks = pieces.length ? pieces : [cand]; |
| for (const chunk of chunks) { |
| const call = normaliseCall(tryParse(chunk)); |
| if (!call) continue; |
| call.arguments = coerceArgs(call.name, call.arguments, schemas); |
| const key = `${call.name}:${JSON.stringify(call.arguments)}`; |
| if (seen.has(key)) continue; |
| seen.add(key); |
| calls.push(call); |
| } |
| } |
|
|
| |
| const visible = consumed.replace(/\s+/g, " ").trim(); |
|
|
| return { reasoning, calls, text: visible }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export function foldSystemIntoUser(messages) { |
| if (!messages.length || messages[0].role !== "system") return messages; |
| const [sys, ...rest] = messages; |
| const firstUser = rest.findIndex((m) => m.role === "user"); |
| if (firstUser === -1) return [{ role: "user", content: sys.content }, ...rest]; |
| const copy = [...rest]; |
| copy[firstUser] = { |
| role: "user", |
| content: `${sys.content}\n\n---\n\n${copy[firstUser].content}`, |
| }; |
| return copy; |
| } |
|
|
| |
| |
| export function mergeAlternating(messages) { |
| const out = []; |
| for (const m of messages) { |
| const last = out[out.length - 1]; |
| if (last && last.role === m.role) { |
| last.content = `${last.content}\n${m.content}`; |
| } else { |
| out.push({ ...m }); |
| } |
| } |
| return out; |
| } |
|
|