aerix.js
Aerix's turn loop in the browser: an agent with tools, hooks and named limits, running on a
caldrix.js engine. On npm it is @caldrix-ai/aerix.
aerix.js loads nothing itself. You load a model with caldrix.js and hand its Engine to an Agent.
The loop asks the engine for one turn at a time, runs the tools the turn calls, and feeds the results
back until the model answers with a final or a limit is reached.
Install
npm install @caldrix-ai/caldrix @caldrix-ai/aerix
@caldrix-ai/caldrix is a peer dependency (^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0, the caldrix.js that reads a model's chat_template.jinja).
Example
import { Caldrix } from "@caldrix-ai/caldrix";
import { Agent, tool } from "@caldrix-ai/aerix";
const engine = await Caldrix.load("smollm2-instruct");
const weather = tool({
name: "weather",
description: "Today's forecast for a city.",
input: {
city: { type: "string", description: "City name", required: true },
unit: { type: "string", enum: ["c", "f"] },
},
run: async ({ city, unit }) => `Sunny in ${city}, 21°${unit ?? "c"}`,
});
const agent = new Agent({
model: engine,
instructions: "You are a travel helper. Use the tools to answer.",
tools: [weather],
limits: { turns: 8, retries: 2, maxTokens: 384, resultChars: 4000 },
generation: { temperature: 0 },
hooks: { beforeCall: (call) => (call.tool === "weather" ? undefined : { deny: "not allowed" }) },
});
const controller = new AbortController();
for await (const ev of agent.run("Should I pack an umbrella for Lisbon?", { signal: controller.signal })) {
if (ev.type === "final") console.log(ev.text);
}
const { text } = await agent.ask("And for Porto?"); // the same conversation
console.log(text);
agent.reset();
API
The package exports Agent, tool and AerixError, and the types AgentOptions, Limits, Hooks,
AgentEvent, Call, EndReason, Model, Tool, ToolDef, Field and FieldType.
Model is { chat(messages, opts?): AsyncIterable<Delta> } over caldrix.js's types. A caldrix.js
Engine satisfies it.
tool(def) defines a tool:
namematches^[a-z][a-z0-9_]{0,63}$and is unique in the agent (a duplicate is refused when the agent is built).descriptionis the text the model sees.inputis a flat field map. Each field has atype(string,number,integerorboolean) and an optionaldescription,requiredandenum. An empty map means no input.run(input, { signal, callId })returns a string, or a value that goes throughJSON.stringify. The input is checked against the fields beforerun; a failed check or a throw becomes the resulterror: …, and a tool error never ends the run.
new Agent(options) takes AgentOptions:
model(required),instructions(default""),tools(default none; then the only answer is a final).limits, each a named bound:turns(8, generations per run, retries included),retries(2, extra attempts after a bad or cut turn),maxTokens(512, per generation) andresultChars(4000, one tool result is cut to this with a note).generation: caldrix.js'sGenerationOptionswithoutmaxTokensandconstraint, which aerix.js sets.constrain(defaulttrue): send the turn schema as theconstraintof every generation. Setfalsefor aModelwithout constrained decoding; then only parse-and-retry applies.hooks, each optional and awaited:beforeCall(call)may return{ deny: reason }(the result becomesrefused: <reason>) or{ input }(replaces the call's input);afterCall(call, result)may return{ result };onFinal(text)may return{ continue: text }, which gives the agent another turn with that text as a user message, withinturns.
agent.run(prompt, { signal }) yields AgentEvents and continues the agent's conversation:
{ type: "text", text }: raw streamed model text.{ type: "usage", promptTokens, completionTokens }: one per generation.{ type: "turn", turn, thought?, calls }, then per call{ type: "call", id, tool, input }and{ type: "result", id, tool, text, error }.{ type: "retry", turn, reason }: a cut or bad turn is asked again.- Exactly one ending:
{ type: "final", text, thought? }, or{ type: "end", reason, message? }wherereasonis"turns","retries","abort"or"error"(an engine failure or a throwing hook, with its message). An engine failure is this event, never a throw.
agent.ask(prompt, { signal }) runs to the end and resolves { text, events } on a final; any
other ending rejects with AerixError kind end, carrying reason and events.
agent.reset() forgets the conversation. agent.messages is the conversation so far.
One run at a time: a second concurrent run throws AerixError kind busy, and bad options throw
kind options.
The turn contract
Every turn is one JSON object, either {"calls":[{"tool":"…","input":{…}}]} or {"final":"…"}, with an
optional thought. The system message states this contract and lists the tools; it is the same
contract and the same tool rendering as Aerix's.
Every turn is constrained (unless constrain: false): the engine decodes under a schema built from
the tools, so a turn that ends on its own is a valid turn with a known tool and an input of the right
shape, with one exception: an empty calls list passes the constraint, and parsing refuses it
and retries. A turn cut by maxTokens before its JSON closes is dropped and asked again, shorter, as a
retry. At most 8 calls of one turn are kept, a thought is cut at 600 characters, and calls run in
order, one at a time.
Each turn's prompt is the system message plus every earlier turn and result. The engine keeps what the
previous generation processed, so a turn that only appends to the conversation prefills the new part;
the done delta's usage.cachedTokens says how much was reused.
Not built
- Per-option scores for routing (the browser engine returns
logprobs; nothing here reads them). - Subagents and a verifier, compaction and a context tool, sessions kept in IndexedDB.
What has run
- The unit tests, against a scripted fake
Model: the turn parsing, the system rendering and the turn schema (golden), the turn contract pinned to Aerix's, input checks, each hook, each ending and each limit. - One end-to-end test in Playwright's headless Chromium on the wasm tier: an
Agentwith one tool on the built caldrix.js, loading a tiny test model whose output is noise, from a loopback server. It checks that the engine receives the turn schema on every generation, that every retry follows a cut turn, and that the run ends with a named outcome.
Nothing more is claimed: no real model, no other browser or tier, and no quality or speed figure.
License
aerix.js (the npm package @caldrix-ai/aerix) is under Apache-2.0 (LICENSE, NOTICE).