import { Hooks } from './hooks.ts'; import { Metrics } from './metrics.ts'; import { type ToolCall } from './toolcalls.ts'; import { type Device, type RuntimeOptions } from './runtime.ts'; import { type ModelSource } from './source.ts'; export type ToolHandler = (args: Record) => unknown | Promise; export interface ToolSchema { type: 'function'; function: { name: string; description: string; parameters: { type: 'object'; properties: Record; required: string[]; }; }; } export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; name?: string; } export interface LoadOptions extends RuntimeOptions { /** Quantization variant. 'auto' probes which variants the source has. */ dtype?: string | 'auto'; /** 'auto' (default) uses WebGPU when available, else WASM/CPU. */ device?: Device; onProgress?: (p: unknown) => void; } export interface ChatOptions { maxNewTokens?: number; /** Whether a tool call is optional or mandatory for this turn. * * 'auto' (default) — generate normally; if the model produced no tool call * and tools are registered, generate ONCE more with the call syntax already * started, which leaves it no way to answer except by completing a call. * 'required' — start the call syntax immediately, skipping the free turn. * 'none' — never force; the model answers or it doesn't. */ toolChoice?: 'auto' | 'required' | 'none'; /** Divides the logit of any token already generated, so the next one is less * likely to be the same. Decoding is greedy, and greedy decoding on a small * model has no escape from a loop: once a phrase becomes the argmax it stays * the argmax forever. This is the only thing that breaks that cycle — no * system prompt can, because the loop is not a comprehension failure. * * 1.1 by default: enough to break loops, mild enough that the structural * tokens JSON legitimately repeats (`"`, `,`, `:`) still win their positions. * Set 1 to disable. Above ~1.2 tool-call JSON starts to malform. */ repetitionPenalty?: number; /** How the tool call is extracted from the model. * * 'auto' (default) — inline first, because a model trained on tool calling * does it in one generation. If that produces no call, and priming the call * syntax does not either, fall back to stepwise rather than give up. You do * not have to know which models need this, which is the point: the library * finds out per question, at no cost to models that never need it. * * 'inline' — one generation produces the whole call as JSON, and if that * fails, it failed. Use this to opt out of the extra round trips. * * 'stepwise' — skip inline entirely. Ask a closed question to pick the tool, * then one question per argument, and assemble the call here. The tool name * is CHOSEN from your list rather than WRITTEN by the model, so a * hallucinated name cannot be produced, and no step requires emitting valid * JSON. Measured: Qwen2.5-0.5B at q8 emits {"name": "rain"} inline and * selects correctly stepwise — and on models that already work it is no * slower, because 1+N short generations beat one 256-token one. */ strategy?: 'auto' | 'inline' | 'stepwise'; } /** Verdict from {@link NexusChat.selfCheck}. */ export interface ToolCallCheck { /** Called a tool AND answered from its result. The only value worth gating on. */ ok: boolean; called: boolean; grounded: boolean; /** True when the model only called because the library primed the syntax — * it works, but it is closer to the edge than a model that volunteers. */ needed_forcing: boolean; model: string; device: string; dtype: string; answer: string; /** One sentence you can show a user verbatim. */ detail: string; } type ChatEvents = { token: [string]; toolCall: [ToolCall, unknown]; round: [number]; answer: [string]; metric: [string, number]; /** The prompt as the model actually received it, per round. The single most * useful thing to see when a model won't call a tool — it shows whether the * schemas and tool results really made it into the template. */ prompt: [string, number]; /** Raw generation before parsing, per round, with what was parsed out of it. * "The model answered instead of calling" and "the model called but we * failed to parse it" look identical from the outside without this. */ raw: [string, ToolCall[], number]; /** A forced attempt: the primed generation, what was salvaged from it (null * if nothing trustworthy), and the round. Without this a discarded forced * turn is invisible — you see "no tool call" and cannot tell whether the * model refused, named something unregistered, or emitted unparseable text. */ forced: [string, ToolCall | null, number]; /** One stepwise question: which step ('select' | 'arg:'), the model's * raw reply, what it resolved to, and the round. Stepwise is many small * generations, so without this a wrong argument is invisible — you see a * bad call and cannot tell which question produced it. */ step: [string, string, string | null, number]; }; /** Tool-calling chat over a converted browser model. * * const chat = await NexusChat.load('Qwen/Qwen3-0.6B'); * chat.tool('get_weather', 'Current weather', { city: 'string' }, getWeather); * chat.on('token', t => render(t)); * const answer = await chat.chat('Weather in Chennai?'); */ export declare class NexusChat extends Hooks { private generator; readonly dtype: string; readonly device: string; private tjs; readonly modelId: string; readonly metrics: Metrics; maxRounds: number; /** Steers the model *toward* calling a tool, before any results exist. * * Small models follow a worked example far better than an instruction, so * this shows the exact bytes expected rather than describing them. Measured * on Qwen2.5-0.5B: the earlier prose-only prompt left q8 calling 0/3. */ systemPrompt: string; /** How a forced tool call is started. The parser accepts this tag from any * model family, so priming it works even where the model was trained on a * different call syntax. */ toolCallPrefix: string; /** Replaces `systemPrompt` once tool results are in the conversation. * * These have to be two different instructions. "You MUST call the tool * instead of guessing" is what makes a small model emit a call in round one * — and the same sentence, still in context in round two, reads as *the * tool has not been called yet*, so the model apologises for a failure that * never happened instead of reading the result sitting right above it. * Measured on Qwen2.5-0.5B: the call-phase prompt answers "there was an * error while fetching the weather information" with a perfectly good * tool_response in context; this one reports the value. */ answerPrompt: string; messages: ChatMessage[]; private tools; private constructor(); /** * Load a chat model. The source is always explicit — this library never * guesses a host or a path convention: * * NexusChat.load({ hub: 'onnx-community/Qwen3-0.6B-ONNX' }) // Hugging Face * NexusChat.load({ base: '/models/', id: 'Qwen/Qwen3-0.6B' }) // your server * NexusChat.load({ archive: fileFromInput }) // a portable zip * NexusChat.load({ archive: 'https://host/model.zip' }) */ static load(source: ModelSource, opts?: LoadOptions): Promise; /** Load a model that provably calls tools — or fail loudly saying nothing did. * * `load()` picks the first dtype the host *serves*, which is a statement * about the host and not about whether anything works. This asks the only * question that matters: it loads a candidate, runs {@link selfCheck} against * a throwaway tool returning an unguessable token, and keeps the first one * that both calls the tool and answers from its result. A candidate that * fails is disposed before the next is tried, so only one model is resident. * * const chat = await NexusChat.loadForTools({ hub: 'onnx-community/Qwen3-0.6B-ONNX' }); * * Pass an explicit `dtype` and that is the only candidate — this still tells * you whether it works, it just will not go looking for another. Every * attempt is reported through `onAttempt` so a UI can narrate the retry * rather than appear to hang on a second download. * * Cost is the honest tradeoff: a rejected candidate was still downloaded. * Weights are cached, so it is paid once per dtype per browser. */ static loadForTools(source: ModelSource, opts?: LoadOptions & { /** Called after each candidate is judged, pass or fail. */ onAttempt?: (check: ToolCallCheck) => void; /** Accept a model that only calls when the syntax is primed. Default true — * forcing is a supported path, not a defect. Set false to demand a model * that volunteers the call unaided. */ allowForcing?: boolean; }): Promise; /** Register a tool. Properties accept shorthand: { city: 'string' }. */ tool(name: string, description: string, properties: Record>, handler: ToolHandler, opts?: { required?: string[]; }): this; get toolSchemas(): ToolSchema[]; /** Load a tools file by URL and register everything it defines. * * await chat.loadTools('./tools.js'); * * The whole point of keeping tools in one plain `.js` file is that it stays * a file — editable, diffable, servable, swappable at runtime without a * rebuild. Fetching it and passing the text to {@link evalTools} is three * lines every caller was going to write identically, including the error * handling nobody writes: a 404 on a tools file otherwise arrives as * "tool is not defined" from inside eval, which points at the wrong thing. * * The file is NOT an ES module — it is a body that calls `tool(...)`, so it * needs no export and no build step. Pass `fetch` to route it through your * own loader (auth headers, a bundler's ?raw import, a test double). */ loadTools(url: string | URL, opts?: { fetch?: typeof fetch; }): Promise; /** Evaluate user-written JS that defines tools via `tool(...)` — the * decorator pattern as a function. Replaces existing tools. */ evalTools(code: string): Promise; private generate; /** Ask a one-off closed question in the conversation's context, without * putting it in the history. Short cap: every stepwise question has a * one-token-ish answer, and a long budget only gives room to ramble. */ private probe; /** Build a tool call by asking closed questions instead of asking for JSON. * * Two properties make this hard to get wrong. The tool name is matched * against the registered list rather than parsed out of free text, so the * model can pick wrong but cannot invent — and arguments are collected one * at a time as bare values, so there is no JSON for it to malform. What the * model is asked to do at each step is roughly "say one word". */ private stepwiseCall; /** Run the handlers and put their results in the conversation. * * Shared by both strategies on purpose: whether the call was parsed out of * JSON or assembled from closed questions, everything downstream — metrics, * the toolCall event, the tool message the answer phase reads — must be * identical, or stepwise would be a second code path that silently drifts. * * `assistantText` is what the model actually produced; stepwise has no such * text, so it records the call it built instead of nothing, keeping the * transcript readable. */ private dispatch; /** Chat with the automatic tool loop; returns the final grounded answer. */ chat(userText: string, opts?: ChatOptions): Promise; /** Can THIS model, as loaded, actually call a tool and answer from it? * * The published matrix cannot cover a model someone just uploaded from a * zip or served from their own host, so ask the model itself: register a * throwaway tool whose result is a token nothing could guess, ask for it, * and see whether the token comes back in the answer. Roughly one * generation pair — cheap next to loading the weights. * * Registered tools and conversation history are saved and restored, so this * is safe to run immediately after load. Note that `token`/`toolCall`/`raw` * hooks DO fire during the check; ignore them by their round if your UI * cares. * * const check = await chat.selfCheck(); * if (!check.ok) warn(check.detail); */ selfCheck(opts?: ChatOptions): Promise; reset(): void; dispose(): Promise; } export {};