UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

264 lines • 10.6 kB
import { errorMessageOf, HttpModelError } from '../openai-embeddings.js'; /** Thrown internally when a budget or deadline would be exceeded; callers turn it into a fallback. */ export class TreeBudgetExceededError extends Error { limit; constructor(limit) { super(`tree LLM budget exhausted (${limit})`); this.limit = limit; this.name = 'TreeBudgetExceededError'; } } /** ~4 characters per token: the budget's estimate when a provider reports no usage. */ export function estimateTokens(text) { return Math.ceil(text.length / 4); } /** Tracks spend against a {@link TreeBudget} and runs calls under it. Not exported from the package. */ export class BudgetedLlm { llm; budget; outerSignal; calls = 0; inputTokens = 0; outputTokens = 0; exhausted = false; deadline; constructor(llm, budget, outerSignal) { this.llm = llm; this.budget = budget; this.outerSignal = outerSignal; this.deadline = budget.timeoutMs !== undefined ? Date.now() + budget.timeoutMs : Number.POSITIVE_INFINITY; } /** Time left before the deadline, ms (`Infinity` without one). */ remainingMs() { return this.deadline - Date.now(); } /** Would a call with this prompt fit? Does not reserve anything; a `false` marks the budget exhausted. */ fits(prompt, maxOutputTokens) { const fits = this.limitHit(prompt, maxOutputTokens) === undefined; if (!fits) { this.exhausted = true; } return fits; } limitHit(prompt, maxOutputTokens) { if (this.calls >= this.budget.maxCalls) { return 'calls'; } if (this.inputTokens + estimateTokens(prompt) > this.budget.maxInputTokens) { return 'inputTokens'; } // A reply may be shorter than its cap; only refuse when nothing at all is left. if (this.outputTokens + Math.min(maxOutputTokens, 16) > this.budget.maxOutputTokens) { return 'outputTokens'; } if (this.remainingMs() <= 0 || this.outerSignal?.aborted) { return 'timeout'; } return undefined; } /** * Make one call, or throw {@link TreeBudgetExceededError} without calling. Provider errors * propagate (after being counted). Never retries. */ async call(task, system, prompt, maxOutputTokens) { const limit = this.limitHit(system + prompt, maxOutputTokens); if (limit !== undefined) { this.exhausted = true; throw new TreeBudgetExceededError(limit); } const outputCap = Math.max(16, Math.min(maxOutputTokens, this.budget.maxOutputTokens - this.outputTokens)); const timeoutMs = Math.min(this.budget.callTimeoutMs ?? Number.POSITIVE_INFINITY, this.remainingMs()); // Reserve the estimate now so concurrent calls cannot all pass the check on the same headroom; // it is corrected to the reported usage when the reply arrives. const reservedInput = estimateTokens(system + prompt); this.inputTokens += reservedInput; this.outputTokens += outputCap; const controller = new AbortController(); const onOuterAbort = () => controller.abort(this.outerSignal?.reason); this.outerSignal?.addEventListener('abort', onOuterAbort, { once: true }); let timer; this.calls += 1; try { const response = await Promise.race([ this.llm({ task, system, prompt, maxOutputTokens: outputCap, signal: controller.signal }), new Promise((_, reject) => { if (Number.isFinite(timeoutMs)) { timer = setTimeout(() => { controller.abort(new TreeBudgetExceededError('timeout')); reject(new TreeBudgetExceededError('timeout')); }, Math.max(0, timeoutMs)); } }), ]); this.inputTokens += (response.usage?.inputTokens ?? reservedInput) - reservedInput; this.outputTokens += (response.usage?.outputTokens ?? estimateTokens(response.text)) - outputCap; return response.text; } catch (error) { // A failed call keeps its input estimate (the provider may have billed it) but no output. this.outputTokens -= outputCap; if (error instanceof TreeBudgetExceededError) { this.exhausted = true; } throw error; } finally { if (timer !== undefined) { clearTimeout(timer); } this.outerSignal?.removeEventListener('abort', onOuterAbort); } } } /** * Pull the JSON object out of a model reply: bare, fenced in ```json, or surrounded by prose. * Returns `undefined` when there is none (never throws). */ export function parseJsonReply(text) { const tryParse = (candidate) => { try { const value = JSON.parse(candidate); return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined; } catch { return undefined; } }; const trimmed = text.trim(); const direct = tryParse(trimmed); if (direct !== undefined) { return direct; } const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed); if (fenced?.[1] !== undefined) { const inner = tryParse(fenced[1].trim()); if (inner !== undefined) { return inner; } } const start = trimmed.indexOf('{'); const end = trimmed.lastIndexOf('}'); if (start >= 0 && end > start) { return tryParse(trimmed.slice(start, end + 1)); } return undefined; } /** The strings in a JSON array value (numbers stringified); anything else yields `[]`. */ export function stringList(value) { if (!Array.isArray(value)) { return []; } return value .map((item) => (typeof item === 'number' ? String(item) : item)) .filter((item) => typeof item === 'string'); } const NOOP_SINK = { write() { }, end() { }, }; /** * A {@link TreeLlm} over the agent's own {@link ModelProvider}: one `runTurn` with no tools and a * discarding sink. Usage comes from the provider's report. */ export function treeLlmFromModelProvider(provider) { return async (request) => { const result = await provider.runTurn({ system: request.system, messages: [{ role: 'user', content: request.prompt }], tools: [], sink: NOOP_SINK, abortSignal: request.signal, }); return { text: result.text, usage: { inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens }, }; }; } /** * A {@link TreeLlm} over any OpenAI-compatible `POST /v1/chat/completions` — OpenAI, a gateway, * vLLM, Ollama, DeepSeek, Qwen… — with no SDK dependency. Throws `HttpModelError` on a non-2xx. */ export function openAiChatTreeLlm(options) { const url = `${(options.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '')}/chat/completions`; const doFetch = options.fetch ?? fetch; return async (request) => { const json = request.task.kind !== 'summarize'; const response = await doFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}), ...options.headers, }, body: JSON.stringify({ model: options.model, messages: [ { role: 'system', content: request.system }, { role: 'user', content: request.prompt }, ], temperature: options.temperature ?? 0, max_tokens: request.maxOutputTokens, ...(options.jsonMode && json ? { response_format: { type: 'json_object' } } : {}), ...options.body, }), signal: request.signal, }); const body = await response.text(); if (!response.ok) { throw new HttpModelError(response.status, `Chat completion failed (${response.status}): ${errorMessageOf(body)}`); } const parsed = parseJsonReply(body); const content = parsed?.choices?.[0]?.message?.content; if (typeof content !== 'string') { throw new HttpModelError(502, 'Chat completion returned no message content'); } const usage = {}; if (typeof parsed?.usage?.prompt_tokens === 'number') { usage.inputTokens = parsed.usage.prompt_tokens; } if (typeof parsed?.usage?.completion_tokens === 'number') { usage.outputTokens = parsed.usage.completion_tokens; } return { text: content, usage }; }; } /** * Memoize a {@link TreeLlm} by the exact request (task kind, system prompt, prompt, output cap). * With a deterministic model that makes a rebuild of an unchanged section, or a repeated question, * free. Cached replies report zero usage, so they cost nothing against a budget but a call slot. */ export function cachedTreeLlm(llm, cache = new Map()) { return async (request) => { const key = stableHash(`${request.task.kind}\u0000${request.maxOutputTokens}\u0000${request.system}\u0000${request.prompt}`); const hit = await cache.get(key); if (hit !== undefined) { return { text: hit.text, usage: { inputTokens: 0, outputTokens: 0 } }; } const response = await llm(request); await cache.set(key, response); return response; }; } /** * A fast, deterministic, non-cryptographic 64-bit hash (two independent 32-bit FNV-1a/murmur-mixed * lanes) as 16 hex chars — content fingerprints and cache keys, not security. */ export function stableHash(text) { let h1 = 0x811c9dc5; let h2 = 0x01000193 ^ text.length; for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = Math.imul(h1 ^ c, 0x01000193); h2 = Math.imul(h2 ^ c, 0x5bd1e995); h2 ^= h2 >>> 13; } h1 = Math.imul(h1 ^ (h1 >>> 16), 0x85ebca6b); h2 = Math.imul(h2 ^ (h2 >>> 15), 0xc2b2ae35); return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0'); } //# sourceMappingURL=llm.js.map