| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { TOOL_SCHEMAS, runTool } from "./tools.js"; |
| import { |
| buildSystemPrompt, |
| buildFewShot, |
| formatToolResult, |
| parseToolCalls, |
| splitReasoning, |
| coerceArgs, |
| foldSystemIntoUser, |
| mergeAlternating, |
| STOP, |
| CALL_OPEN, |
| CALL_CLOSE, |
| } from "./protocol.js"; |
|
|
| const ROUTER = "https://router.huggingface.co/v1/chat/completions"; |
| const MAX_TURNS = 6; |
|
|
| |
| const NO_SYSTEM_ROLE = [/^google\/gemma-2/i, /^google\/gemma-3/i]; |
|
|
| |
| const STRICT_ALTERNATION = [/^google\/gemma/i, /^mistralai\//i]; |
|
|
| const matches = (model, patterns) => patterns.some((p) => p.test(model)); |
|
|
| export class InferenceError extends Error { |
| constructor(message, { status, kind } = {}) { |
| super(message); |
| this.status = status; |
| this.kind = kind; |
| } |
| } |
|
|
| |
| |
| |
|
|
| async function callRouter({ token, model, provider, messages, tools, maxTokens, temperature, stop }) { |
| const body = { |
| |
| |
| model: provider ? `${model}:${provider}` : model, |
| messages, |
| max_tokens: maxTokens ?? 700, |
| temperature: temperature ?? 0.2, |
| }; |
| if (tools) { |
| body.tools = tools; |
| body.tool_choice = "auto"; |
| } |
| if (stop) body.stop = stop; |
|
|
| let res; |
| try { |
| res = await fetch(ROUTER, { |
| method: "POST", |
| headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, |
| body: JSON.stringify(body), |
| }); |
| } catch (e) { |
| throw new InferenceError(`Network error reaching the HF router: ${e.message}`, { |
| kind: "network", |
| }); |
| } |
|
|
| const text = await res.text(); |
| const { json, embeddedError } = parseRouterBody(text); |
|
|
| |
| |
| |
| if (res.ok && embeddedError) { |
| throw new InferenceError(`Provider failed mid-request: ${embeddedError}`, { |
| status: 200, |
| kind: "provider", |
| }); |
| } |
|
|
| if (!res.ok) { |
| const detail = json?.error?.message || json?.error || text.slice(0, 300); |
| let kind = "http"; |
| if (res.status === 401 || res.status === 403) kind = "auth"; |
| else if (res.status === 402) kind = "credits"; |
| else if (res.status === 429) kind = "ratelimit"; |
| else if (/tool|function/i.test(String(detail))) kind = "tools_unsupported"; |
| throw new InferenceError(friendlyError(res.status, detail), { status: res.status, kind }); |
| } |
|
|
| return json; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function parseRouterBody(text) { |
| try { |
| return { json: JSON.parse(text), embeddedError: null }; |
| } catch { |
| |
| } |
|
|
| const objects = []; |
| let depth = 0, |
| start = -1, |
| inStr = false, |
| esc = false; |
| for (let i = 0; i < text.length; i++) { |
| const c = text[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) { |
| objects.push(text.slice(start, i + 1)); |
| start = -1; |
| } |
| } |
| } |
|
|
| let json = null; |
| let embeddedError = null; |
| for (const raw of objects) { |
| let obj; |
| try { |
| obj = JSON.parse(raw); |
| } catch { |
| continue; |
| } |
| if (obj.error) { |
| embeddedError = obj.error.message || String(obj.error); |
| } else if (!json) { |
| json = obj; |
| } |
| } |
|
|
| |
| |
| if (json && embeddedError) { |
| const content = json.choices?.[0]?.message?.content; |
| if (content) embeddedError = null; |
| } |
|
|
| if (!json && !embeddedError) embeddedError = `unparseable response: ${text.slice(0, 200)}`; |
| return { json, embeddedError }; |
| } |
|
|
| function friendlyError(status, detail) { |
| if (status === 401 || status === 403) { |
| return "Token rejected. Check that it is a valid HF token with Inference Providers access."; |
| } |
| if (status === 402) { |
| return "HF Inference credits exhausted for this token. Sign in with your own account, or top up at huggingface.co/settings/billing."; |
| } |
| if (status === 429) return "Rate limited by the provider. Wait a moment and retry."; |
| return `Provider returned HTTP ${status}: ${detail}`; |
| } |
|
|
| |
| |
| |
|
|
| function shapeMessages(model, messages) { |
| let out = messages; |
| if (matches(model, NO_SYSTEM_ROLE)) out = foldSystemIntoUser(out); |
| if (matches(model, STRICT_ALTERNATION)) out = mergeAlternating(out); |
| return out; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function runAgent({ |
| userMessage, |
| history = [], |
| model, |
| provider, |
| token, |
| mode = "auto", |
| onEvent = () => {}, |
| }) { |
| let active = mode === "auto" ? "native" : mode; |
| let fellBack = false; |
|
|
| for (;;) { |
| try { |
| const result = await runLoop({ |
| userMessage, |
| history, |
| model, |
| provider, |
| token, |
| mode: active, |
| onEvent, |
| }); |
| return { ...result, mode: active, fellBack }; |
| } catch (e) { |
| const canFallBack = |
| mode === "auto" && |
| active === "native" && |
| (e.kind === "tools_unsupported" || |
| e.kind === "http" || |
| e.kind === "provider" || |
| e.kind === "no_native_tools"); |
|
|
| if (!canFallBack) throw e; |
|
|
| onEvent({ |
| type: "fallback", |
| reason: e.message, |
| detail: |
| "Native tool calling was refused by this model/provider. Retrying with the prompted protocol.", |
| }); |
| active = "prompted"; |
| fellBack = true; |
| } |
| } |
| } |
|
|
| async function runLoop({ userMessage, history, model, provider, token, mode, onEvent }) { |
| const prompted = mode === "prompted"; |
|
|
| const messages = []; |
| let fewShotCount = 0; |
| if (prompted) { |
| messages.push({ role: "system", content: buildSystemPrompt(TOOL_SCHEMAS) }); |
| |
| |
| |
| const shots = buildFewShot(); |
| fewShotCount = shots.length; |
| messages.push(...shots); |
| } else { |
| messages.push({ |
| role: "system", |
| content: |
| "You are a financial assistant with access to live market data tools. " + |
| "You cannot know prices from memory: always use the tools for price, market-cap, " + |
| "history or currency questions. Answer concisely once you have the data.", |
| }); |
| } |
| messages.push(...history, { role: "user", content: userMessage }); |
|
|
| let turn = 0; |
| let nudged = false; |
| let toolsRun = 0; |
|
|
| for (turn = 1; turn <= MAX_TURNS; turn++) { |
| onEvent({ type: "turn_start", turn }); |
|
|
| let payload; |
| try { |
| payload = await callRouter({ |
| token, |
| model, |
| provider, |
| messages: shapeMessages(model, messages), |
| tools: prompted ? undefined : TOOL_SCHEMAS, |
| stop: prompted ? STOP : undefined, |
| }); |
| } catch (e) { |
| |
| |
| |
| |
| |
| if (e.kind === "provider" && prompted && fewShotCount > 0) { |
| onEvent({ |
| type: "degrade", |
| turn, |
| reason: e.message, |
| detail: "Retrying without the few-shot examples, on a smaller payload.", |
| }); |
| messages.splice(1, fewShotCount); |
| fewShotCount = 0; |
| payload = await callRouter({ |
| token, |
| model, |
| provider, |
| messages: shapeMessages(model, messages), |
| stop: STOP, |
| }); |
| } else { |
| throw e; |
| } |
| } |
|
|
| const msg = payload?.choices?.[0]?.message; |
| if (!msg) throw new InferenceError("Provider returned no message.", { kind: "http" }); |
|
|
| const usage = payload.usage; |
| if (usage) onEvent({ type: "usage", turn, usage }); |
|
|
| |
| if (!prompted) { |
| const native = msg.tool_calls || []; |
|
|
| |
| |
| const parsed = parseToolCalls(msg.content || "", TOOL_SCHEMAS); |
|
|
| if (!native.length && !parsed.calls.length) { |
| const { reasoning, text } = splitReasoning(msg.content || ""); |
| if (reasoning) onEvent({ type: "reasoning", turn, text: reasoning }); |
|
|
| |
| |
| if (turn === 1 && needsTools(userMessage)) { |
| throw new InferenceError( |
| "Model returned prose instead of a tool call on the first turn.", |
| { kind: "no_native_tools" } |
| ); |
| } |
| onEvent({ type: "final", turn, text }); |
| return { answer: text, turns: turn }; |
| } |
|
|
| const calls = native.length |
| ? native.map((c) => ({ |
| id: c.id, |
| name: c.function?.name, |
| arguments: safeArgs(c.function?.arguments), |
| })) |
| : parsed.calls.map((c, i) => ({ id: `parsed_${turn}_${i}`, ...c })); |
|
|
| if (msg.content) { |
| const { reasoning, text } = splitReasoning(msg.content); |
| if (reasoning) onEvent({ type: "reasoning", turn, text: reasoning }); |
| if (text) onEvent({ type: "thought", turn, text }); |
| } |
|
|
| const results = await executeAll(calls, turn, onEvent); |
| toolsRun += results.length; |
|
|
| messages.push({ |
| role: "assistant", |
| content: msg.content || null, |
| tool_calls: calls.map((c) => ({ |
| id: c.id, |
| type: "function", |
| function: { name: c.name, arguments: JSON.stringify(c.arguments) }, |
| })), |
| }); |
| for (const r of results) { |
| messages.push({ role: "tool", tool_call_id: r.id, content: JSON.stringify(r.result) }); |
| } |
| continue; |
| } |
|
|
| |
| const raw = msg.content || ""; |
| const { reasoning, calls, text } = parseToolCalls(raw, TOOL_SCHEMAS); |
|
|
| if (reasoning) onEvent({ type: "reasoning", turn, text: reasoning }); |
|
|
| if (!calls.length) { |
| |
| |
| |
| |
| |
| |
| if (!nudged && toolsRun === 0 && needsTools(userMessage)) { |
| nudged = true; |
| onEvent({ |
| type: "nudge", |
| turn, |
| text: text || "(empty reply)", |
| detail: "No tool call emitted. Re-prompting with an explicit format reminder.", |
| }); |
| messages.push({ role: "assistant", content: text || "..." }); |
| messages.push({ |
| role: "user", |
| content: |
| `You did not emit a tool call, so no data was retrieved. Do not describe ` + |
| `what you intend to do. Reply with ONLY the tool call block and nothing ` + |
| `else, starting at the very first character:\n` + |
| `${CALL_OPEN}{"name": "...", "arguments": {...}}${CALL_CLOSE}`, |
| }); |
| continue; |
| } |
|
|
| onEvent({ type: "final", turn, text }); |
| return { answer: text, turns: turn }; |
| } |
|
|
| if (text) onEvent({ type: "thought", turn, text }); |
|
|
| const withIds = calls.map((c, i) => ({ id: `p_${turn}_${i}`, ...c })); |
| const results = await executeAll(withIds, turn, onEvent); |
| toolsRun += results.length; |
|
|
| |
| |
| |
| messages.push({ role: "assistant", content: rebuildAssistantTurn(text, withIds) }); |
| messages.push({ |
| role: "user", |
| content: results.map((r) => formatToolResult(r.name, r.result)).join("\n"), |
| }); |
| } |
|
|
| onEvent({ |
| type: "final", |
| turn, |
| text: |
| "I reached the maximum number of tool-calling turns without settling on an answer. " + |
| "Try rephrasing, or ask for one thing at a time.", |
| }); |
| return { answer: "Turn limit reached.", turns: MAX_TURNS }; |
| } |
|
|
| |
| |
| |
|
|
| function safeArgs(raw) { |
| if (!raw) return {}; |
| if (typeof raw === "object") return raw; |
| try { |
| return JSON.parse(raw); |
| } catch { |
| return {}; |
| } |
| } |
|
|
| |
| |
| function needsTools(q) { |
| return /price|worth|cost|market|cap|convert|trend|change|how much|vs\.?|compare|rate|usd|eur|try|btc|eth/i.test( |
| q |
| ); |
| } |
|
|
| function rebuildAssistantTurn(text, calls) { |
| const blocks = calls |
| .map((c) => `<tool_call>${JSON.stringify({ name: c.name, arguments: c.arguments })}</tool_call>`) |
| .join("\n"); |
| return text ? `${text}\n${blocks}` : blocks; |
| } |
|
|
| async function executeAll(calls, turn, onEvent) { |
| return Promise.all( |
| calls.map(async (c) => { |
| const args = coerceArgs(c.name, c.arguments || {}, TOOL_SCHEMAS); |
| onEvent({ type: "tool_call", turn, name: c.name, args }); |
| const started = Date.now(); |
| const result = await runTool(c.name, args); |
| onEvent({ |
| type: "tool_result", |
| turn, |
| name: c.name, |
| result, |
| ms: Date.now() - started, |
| failed: !!result?.error, |
| }); |
| return { id: c.id, name: c.name, result }; |
| }) |
| ); |
| } |
|
|