Embedding the AJS VM

This chapter is for the host: the application or service that runs AJS agents written by someone else, a user or a language model. It covers where to parse, every option vm.run takes, what comes back, which failures an agent can catch and which end the run, how quotas hold across nested runs, and where to put the VM.

Two other chapters sit beside this one. The AJS Language is for the people and models who write agents. Capabilities and Custom Atoms covers giving agents new powers: writing a capability and defining an atom. The guarantees the sandbox makes, and the ones it does not, are listed once, in Safe Eval; the invariants behind fuel and the heap ceiling are in VM budgets.

Five words carry most of this chapter:

A complete host

This host lets an agent fetch from one API and ask a model one question, and nothing else. Everything below it in this chapter explains a piece of it.

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const vm = new AgentVM()

// 1. Transpile on the side that owns the source. Here it is a function in the same file;
//    in production it runs on the caller's machine, in a worker or in another process.
function compile(source) {
  try {
    const { ast } = transpile(source, {
      atoms: vm.atoms, // check calls against the atoms this VM really has
      maxSourceBytes: 16 * 1024, // refuse oversized source before parsing it
    })
    return { ast: JSON.stringify(ast) } // the wire and storage format
  } catch (e) {
    // e.g. "Unsupported statement type: ForStatement. AJS has no `for` loops. Use `while`
    //       with a counter: … at <source>:1:19"
    return { problem: e.message }
  }
}

// What a model might have written:
const { ast, problem } = compile(`
/**
 * Fetch a city's forecast and summarize it in one sentence.
 * @param city - the city to look up
 */
function forecast({ city: 'Paris' }): { city: '', summary: '' } {
  let data = httpFetch({ url: \`https://api.weather.example/v1/\${city}\` })
  let summary = llmPredict({ prompt: \`One sentence: \${JSON.stringify(data)}\` })
  return { city, summary }
}
`)
if (problem) throw new Error(problem)

// 2. The capabilities: the only ways out of the sandbox for this run.
const capabilities = {
  // A stand-in for your HTTP client. It returns the response BODY as plain data.
  fetch: async (url, init) => ({ city: url.split('/').pop(), high: 21, sky: 'clear' }),
  // A stand-in for your model client.
  llm: { predict: async (prompt) => 'Clear skies and a high of 21.' },
}

// 3. Run the stored AST on the VM that has no parser.
const { result, error, fuelUsed, trace } = await vm.run(
  JSON.parse(ast),
  { city: 'Oslo' },
  {
    fuel: 1000, // the work budget
    timeoutMs: 10_000, // someone is waiting for this answer
    capabilities,
    context: { allowedFetchDomains: ['api.weather.example'] }, // where fetch may go
    quotas: { llmPredict: 1, httpFetch: 2 }, // call caps, whatever the fuel allows
    trace: true, // keep an audit trail
  }
)

// 4. Branch on the outcome. vm.run returned; it did not throw.
if (!error) {
  console.log(result, fuelUsed)
  // { city: 'Oslo', summary: 'Clear skies and a high of 21.' }  about 105.6
} else if (error.message === 'Out of Fuel') {
  console.log('the agent ran out of budget at', error.op)
} else if (error.op === 'vm.run') {
  // refused by the VM itself: bad arguments, a broken result contract, the timeout
  console.log('refused:', error.message)
} else {
  // a step failed and the agent did not catch it; error.op names the step
  console.log(error.op, 'failed:', error.message)
}
console.log(trace.map((event) => event.op))
// [ 'varsImport', 'httpFetch', 'llmPredict', 'return', 'seq' ]
// varsImport binds the arguments; seq is the whole program, logged last because an
// event is recorded when its step finishes (see Tracing for audit).

city: 'Paris' is an example, not a default: it declares a required string parameter, and 'Paris' is what one looks like. A parameter written with = (budget = 50.0) has a default instead and may be left out. The : { city: '', summary: '' } after the parameters declares the shape of the result, and the VM checks it. Both become part of the AST (see Arguments and contracts); the language guide has the full rules.

Change one thing and a different branch runs:

Change error.op error.message
allowedFetchDomains: ['other.example'] httpFetch Fetch blocked: domain not in allowlist. Allowed: other.example
arguments { city: 5 } vm.run Input validation failed: args do not match expected schema
a model stand-in that returns 42 llmPredict Output validation failed for 'llmPredict' (it declares a string)
timeoutMs: 20 and a model that never answers vm.run Execution timeout after 20ms. …
fuel: 50 llmPredict Out of Fuel

What to branch on. An AgentError has a message, an op and sometimes a cause, and no machine-readable kind. So today a host tells fuel exhaustion from other failures by its message, as step 4 does, and tells "refused by the VM" from "a step failed" by op. The result does not say whether an uncaught failure was one the agent could have caught or one that ended the run: a run-ending refusal at storeGet arrives with op: 'storeGet', like any failure there. A host rarely needs to know: either way the run failed, and op says where.

Transpile where parsing is safe

An AJS program crosses three places: it is transpiled where its source is handled, it travels as a JSON AST, and it runs on the VM. Keep the first and the last apart.

Parsing is the one step whose cost the author of the source controls and that runs before any budget exists: fuel, the timeout and the heap ceiling all start with vm.run. A run is metered, a parse is not. So:

transpile reports a bad program by throwing a TranspileError with a message, a line and a column, and the message says what to write instead. That is where an agent author, or the model you are prompting, gets help. The VM does not explain: it runs the AST as written, or refuses it.

vm.run(source) on tjs-lang/vm still works and is deprecated. It parses inside the VM host, capped at 8 KB (maxSourceBytes), and notes the deprecation once in the flight recorder (the runtime's log of failures and near-misses, read with __tjs.records()). For agents you wrote yourself, the ajs tagged template (from tjs-lang) returns the AST in one step.

Both VMs also accept a procedure token (proc_…) in place of an AST. The storeProcedure atom returns one: it names an AST kept in a store inside the current process, and it expires (after an hour by default). It is a handle within one process, not a way to store an AST.

Run options

vm.run(ast, args, options) reads every option once, checks it, and refuses a bad value as an error value (Invalid run option fuel: -1 — it must be a non-negative number). An option defined as a getter is refused too: run options are data. The few call mistakes that throw instead are listed under What vm.run returns.

Option Default What it bounds
fuel 1000 Work. Every step costs fuel; the run stops at Out of Fuel. Infinity is no limit.
timeoutMs twice the slowest atom's timeout, at least 60 s Wall-clock time for the whole run, including time spent waiting on capabilities. Infinity is no limit. Unlike the two options below, 0 is not "off": it means already expired.
maxHeapBytes 64 MB Bytes the agent holds live at once. Fuel bounds total work, not peak memory.
membraneMaxBytes 4 MB The size of one value crossing to or from a capability (an atom's input going out, a capability's return coming in).
argsMaxBytes 4 MB The run's arguments. The fuel bounds them too: one unit of fuel pays for 8,000 bytes, and a string counts about 2 bytes per character, so roughly 4,000 characters.
maxSourceBytes 8 KB Source strings: given to vm.run on tjs-lang/vm, or built by an agent for runCode or transpileCode. 0 disables it for trusted source; for agent-built source it can only be lowered.
quotas none Calls per atom, { llmPredict: 3 }. An atom you do not list is unlimited; 0 forbids it.
quotaUsed a fresh counter per run The quota tally. Pass one object to several runs to share it (see Quotas across nested runs).
costOverrides none Fuel cost per atom: a number, or (input, ctx) => number.
timeoutOverrides none Per-atom timeout in ms: a number or a function; 0 disables that atom's timeout.
signal none An AbortSignal. Aborting ends the run with Execution aborted by the caller (options.signal).
trace false Records every step (see Tracing for audit).
capabilities {} The only IO the agent has: fetch, store, llm, agent and any your own atoms read (see Capabilities).
context none Request-scoped data for the VM and your atoms (see Fetch policy).

Sizing fuel. Each atom has a cost, on vm.atoms[name].cost: a number, or a function of the input. Data steps are fractions of a unit, httpFetch costs 5 and llmPredict 100, which is why the host above spends about 105.6. To pick a budget, run the agent once with trace: true on typical arguments, read fuelUsed, and allow headroom for the inputs that make it loop longer.

The default timeout is generous, on purpose. Without timeoutMs, a run gets twice the timeout of the slowest atom registered on the VM, and never less than a minute. Most core atoms time out after one second (control atoms such as seq and while have no timeout of their own). httpFetch and llmPredict are core atoms too, with 30 s and 120 s; the model call is the slowest, so a VM with only core atoms gives a run 240 s, and registering a slower atom raises it. That suits a background job. Anything a person waits on should pass its own timeoutMs.

Each atom also has its own timeout. An atom that times out fails like any other step, and the agent may catch it (Atom 'llmPredict' timed out); the run's own timeout ends the run. When the run ends, for any reason, the VM aborts the run's AbortSignal, so nothing it started is left running. A fetch capability receives that signal as init.signal; pass it on to your HTTP client.

The heap ceiling is not a running total. The VM keeps a cheap estimate that only grows; when the estimate crosses the ceiling, the VM measures the bytes the agent can still reach and fails only if those are over. So a loop that keeps overwriting one variable does not trip it. When it does fail, the message says how much the agent held: Heap limit exceeded: guest state holds ~2MB, limit 1MB. How it stays true is in VM budgets.

Capabilities

The VM has no IO of its own. Each run gets the capabilities you pass and nothing else: fetch for httpFetch, store for the store atoms, llm for llmPredict, agent for agentRun with a named agent, and whatever your own atoms read. A missing one is an ordinary failure (Capability 'llm.predict' missing). With no store, the store atoms use an in-memory store that lasts one run, and the result's warnings says so.

agent is { run(agentId, input) }, returning the sub-agent's RunResult (or its result alone). An agent's agentRun names its sub-agent one of two ways:

// A name: your agent capability decides what runs, as a new vm.run.
let short = agentRun({ agentId: 'summarize', input: { text } })
// An AST value the agent holds (passed in as an argument, or built with transpileCode):
// it runs inline, inside the current run, with no capability involved.
let out = agentRun({ agentId: helper, input: { text } })

The difference matters for quotas.

Everything a capability returns crosses the membrane, which builds a fresh copy: JSON data plus Date. A Response, a Map, a class instance, a function or a getter is refused, and that refusal ends the run (see Catchable failures and run-ending refusals). The interface for each key, the rules for what a capability may return, and how to define your own atoms are in Capabilities and Custom Atoms.

Fetch policy

context is yours: put request metadata in it (a user id, a tenant) and your own atoms read it as ctx.context. The VM itself reads four keys, all for httpFetch:

Key Type Effect
allowedFetchDomains string[] The hosts httpFetch may reach. api.example.com admits the default port only; api.example.com:8443 names one; *.example.com admits the domain and its subdomains.
allowedFetchMethods string[] Methods beyond the default GET and HEAD. CONNECT, TRACE and TRACK are never admitted.
allowedRequestHeaders string[] Header names beyond the default list (below).
requestDepth number How many agent hops deep this request already is. A fetch at depth 10 is refused; 9 is the last allowed.

A list must be an array of strings; a single string is refused rather than read one character at a time.

The request is admitted the same way whoever sends it. The scheme must be http: or https:, the method must be admitted, and every header the agent sets must be on the list. A header that is not on it is refused, not dropped. The default list:

Kind Headers
Content Accept, Accept-Language, Content-Language, Content-Type, Range
Caching Cache-Control, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since
Credentials Authorization, X-API-Key
Tracing User-Agent, X-Request-Id, X-Correlation-Id, X-Requested-With

The credential headers are there because calling an API needs them. An agent can only send a credential it was given, in its arguments or in what a capability returned; it has no secrets of its own. What stops a credential you gave it from going somewhere else is the domain allowlist, so set one whenever an agent holds a key.

When allowedFetchDomains is set it applies on every path, your own fetch capability included.

Without a fetch capability, httpFetch uses a built-in client meant for demonstrations. It fetches nothing unless allowedFetchDomains is set, follows no redirects (a redirect comes back to the agent as { redirect: true, status, location }, and fetching location is a new request, admitted again), sends no cookies or HTTP authentication, and reads the body under membraneMaxBytes.

With your own fetch capability, the VM still admits the scheme, method and headers (and the domain, when allowedFetchDomains is set), then calls your function with the admitted request. Your function owns the rest: redirects, credentials and rate limits. What it receives and what it must decide is in Capabilities and Custom Atoms.

Warning. With your own fetch and no allowedFetchDomains, the VM checks no destination at all. An agent can then ask your function for any URL, internal hosts and cloud metadata addresses included, and only your function stands in the way. Set an allowlist, or make your fetch refuse what it should not reach.

Agent depth. Every request carries an X-Agent-Depth header one higher than context.requestDepth, and an agent cannot set it. An endpoint that runs agents should read the header from the incoming request and pass it back in as requestDepth; then a loop of agents calling agents stops after ten hops (Agent request depth exceeded (max 10)). It is cooperative: it stops accidental loops, not an endpoint that drops the header. In a browser the built-in client sends it only to the page's own origin, because a custom header makes every cross-origin request a CORS preflight.

Eval and SafeFunction, the two-line wrappers over this VM, take the same policy as fetchPolicy: { domains, methods, headers }, which becomes allowedFetchDomains, allowedFetchMethods and allowedRequestHeaders. They cannot set requestDepth.

What vm.run returns

vm.run resolves to a RunResult and does not throw for anything an agent does:

Field What it holds
result The agent's returned object. On failure, the same AgentError as error.
error undefined on success, or an AgentError: message, op (the step that failed, or vm.run), and cause (the host Error behind it, when there was one).
fuelUsed Fuel spent, including what admitting the arguments cost.
trace The step log, when trace: true.
warnings Non-fatal notes, such as the default in-memory store being used.

result holds the error on failure so that code which keeps only result (the function createAgent returns resolves to it) still receives the failure rather than undefined. It also means a failed run's result is not a successful value: always test error, or isAgentError(result), before using it.

An AgentError serializes as { "$error": true, "message": …, "op": … }, so a result sent over the network still says it is an error. isAgentError(value) (from tjs-lang/vm-ast, tjs-lang/vm or tjs-lang) recognises both the object and its JSON form. cause stays on the host side and is not serialized.

A bad option value comes back as an error value. vm.run throws only when what it was handed cannot be a program to run, before anything runs:

Catchable failures and run-ending refusals

Inside an agent, try/catch catches failures of the world: a fetch that fails or is blocked, a store that throws, a model that times out, a quota spent, an answer too large to copy in. A program might reasonably retry or work around any of them. The agent's catch receives the error's message, a capped string, not an Error object.

Some failures end the run, even inside a try. No catch block runs, and no later step. Three rules decide which:

Here is the same agent run against three stores:

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const vm = new AgentVM()
const { ast } = transpile(
  `
function lookup({ id: 'a1' }) {
  try {
    let record = storeGet({ key: id })
    return { record }
  } catch (err) {
    return { failed: err }
  }
}`,
  { atoms: vm.atoms }
)

const run = (get, options = {}) =>
  vm.run(ast, { id: 'a1' }, {
    capabilities: { store: { get, set: async () => {} } },
    ...options,
  })

// The world's failure: the store is down. The agent's catch runs.
const down = await run(async () => {
  throw new Error('store unavailable')
})
console.log(down.result) // { failed: 'store unavailable' }   err is the message string

// The world's failure: the answer is too large. Also catchable.
const big = await run(async () => 'x'.repeat(5000), { membraneMaxBytes: 1024 })
console.log(big.result.failed)
// "Capability boundary rejected the return of 'storeGet': the value exceeds the
//  1024-byte membrane budget"

// The host's bug: JSON cannot express a Map. The run ends; the catch never runs.
const map = await run(async () => new Map([['name', 'Ada']]))
console.log(map.error.op, map.error.message)
// storeGet "Capability boundary rejected the return of 'storeGet': capability return
//  contains a Map; only plain data (and Date) crosses, … use an object, or an array of
//  [key, value] pairs"

Every copy across the boundary is paid for in fuel, refused or not, so an agent that retries a catchable refusal in a loop pays each time. A sub-agent served by your agent capability is a separate run: a refusal ends that run, and the parent sees an ordinary, catchable failure.

Arguments and contracts

The arguments cross the same membrane as a capability's return, before the agent runs:

What the two contracts accept, member by member, is in the language guide. Both live in the AST, so any VM that runs it enforces them, wherever it was transpiled.

Quotas across nested runs

A quota counts calls within one run. A sub-agent started inline (agentRun with an AST) shares its parent's run and its counter. A sub-agent served by your agent capability is a new vm.run, and a new run starts a fresh counter, so an agent that can reach one could multiply its allowance. To hold a cap across the whole call tree, pass the same quotaUsed object to every run in it:

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const vm = new AgentVM()
const compile = (source) => transpile(source, { atoms: vm.atoms }).ast
const agents = {
  summarize: compile(`
function summarize({ text: '' }) {
  let summary = llmPredict({ prompt: \`Summarize: \${text}\` })
  return { summary }
}`),
  triage: compile(`
function triage({ ticket: '' }) {
  let label = llmPredict({ prompt: \`Label: \${ticket}\` })
  let short = agentRun({ agentId: 'summarize', input: { text: ticket } })
  return { label, summary: short.summary }
}`),
}

let modelCalls = 0
const llm = {
  predict: async (prompt) => `answer ${++modelCalls}`,
}

function runAgent(name, input, quotaUsed) {
  return vm.run(agents[name], input, {
    quotas: { llmPredict: 1 },
    quotaUsed,
    capabilities: {
      llm,
      // A named sub-agent is a NEW run. Handing it the same quotaUsed makes it
      // draw on the parent's allowance instead of starting its own.
      agent: { run: (id, input) => runAgent(id, input, quotaUsed) },
    },
  })
}

const separate = await runAgent('triage', { ticket: 'login fails' })
console.log(modelCalls, separate.result)
// 2 { label: 'answer 1', summary: 'answer 2' }   one call per run

modelCalls = 0
const quotaUsed = {}
const shared = await runAgent('triage', { ticket: 'login fails' }, quotaUsed)
console.log(modelCalls, shared.error.op, shared.error.message, quotaUsed)
// 1 agentRun "Quota exceeded for 'llmPredict': 1 call allowed" { llmPredict: 1 }

quotaUsed is trusted host state. The VM checks each count as it reads it (a finite, non-negative number) and refuses a frozen or read-only object up front. A run also keeps its own count of the calls it made: if the shared object is reset to 0 mid-run, that run goes on counting from its own tally. A nested run, though, starts with no tally of its own, so one started with a doctored object believes it. Do not build quotaUsed from anything an agent or a remote party can write. Across a process or network boundary there is nothing to share: budget does not travel, only data does, so each side enforces its own.

Agents as tools

transpile also returns a signature: the entry function's name, its JSDoc description, each parameter's type, example or default and description, and the declared result. createAgent and getToolDefinitions (both from tjs-lang/lang) turn it into something a model can be offered:

import { createAgent, getToolDefinitions } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const shortlist = createAgent(
  `
/**
 * Pick the items that fit a budget.
 * @param budget - the most to spend
 */
function shortlist({ items = [{ name: 'kettle', price: 40.0 }], budget = 50.0 }) {
  let fits = items.filter((item) => item.price <= budget)
  let names = fits.map((item) => item.name)
  return { names }
}`,
  new AgentVM(),
  { fuel: 1000 }
)

await shortlist({ items: [{ name: 'mug', price: 8.5 }], budget: 30 }) // { names: ['mug'] }
shortlist.signature.description // 'Pick the items that fit a budget.'
getToolDefinitions({ shortlist })
// [{ type: 'function', function: { name: 'shortlist',
//    description: 'Pick the items that fit a budget.',
//    parameters: { type: 'object', properties: {
//      items: { type: 'array', items: { type: 'object', properties: {
//        name: { type: 'string' }, price: { type: 'number' } } } },
//      budget: { type: 'number', description: 'the most to spend' } },
//    required: [] } } }]

required is empty because both parameters are written with = and have defaults.

One gap to know about: an integer example (price: 40, budget = 50) currently comes out of getToolDefinitions with no type at all (price: {}), so the model is not told the parameter is a number. Floats, strings, booleans, arrays and objects are typed.

The agent createAgent returns resolves to the run's result, which on failure is the AgentError; check it with isAgentError. createAgent transpiles the source where it runs, with no size cap, so use it for agents you wrote. For source you did not write, keep the transpile-then-run split: getToolDefinitions takes any object with a signature, so the one transpile returned is enough.

import { transpile, getToolDefinitions } from 'tjs-lang/lang'
import { AgentVM, isAgentError } from 'tjs-lang/vm-ast'

const vm = new AgentVM()
// On the transpiling side:
const { ast, signature } = transpile(source, {
  atoms: vm.atoms,
  maxSourceBytes: 16 * 1024,
})
const tools = getToolDefinitions({ shortlist: { signature } })

// On the VM side, when the model calls the tool with arguments:
const { result, error } = await vm.run(ast, args, { fuel: 1000 })

Tracing for audit

With trace: true, the result carries one event per step:

{
  op: 'varsImport',
  input: { keys: ['city'] },
  stateDiff: { city: 'Oslo' }, // the variables the step changed
  result: undefined,
  error: undefined, // the message, when the step failed by throwing
  fuelBefore: 999.878, // not 1000: admitting the arguments already cost a little
  fuelAfter: 999.677,
  timestamp: 1793989773225, // epoch milliseconds
}

An event is recorded when its step finishes, so a block (seq, if, try) appears after the steps inside it, and the last event is the whole program. fuelBefore − fuelAfter is what a step and everything inside it cost; in the host example above, the model call is about 100 of the 105.6. Store the trace beside the AST and the arguments and you can show what an agent did, step by step, after the fact.

Where to run it

The VM runs in any JavaScript environment, browser or server, and the same AST behaves the same in each. Choose by what you can afford to lose:

Because the agent only ever sees atoms, the same program can run against a client-side atom that answers from data already loaded in the browser and a server-side one that queries the datastore, under one capability model. That design, and what it bought in production, is in The universal endpoint.

Further reading