@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
JavaScript
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