DocumentationReference

HazelJS Agent VM Package

npm downloads

Effect-typed agent execution for HazelJS — transactional runs, reversible tools, and speculative multi-branch execution with automatic rollback.

Every agent framework treats a tool call as opaque, irreversible I/O. @hazeljs/​agent-vm changes that: tools declare their effect class, and the runtime gains powers no other agent framework offers today — undo entire agent runs, speculate across K reasoning branches concurrently, and structurally block irreversible side effects inside losing branches.

Quick Reference

  • Purpose: Effect lattice for agent tools + journal-based compensation + speculative branch execution.
  • When to use: Multi-step agents that hold resources, reserve inventory, draft mutations, or explore multiple plans before committing irreversible actions (payments, emails, deletes).
  • Key APIs: @Pure, @Read, @Reversible, @Irreversible, @Compensate, @Atomic, @Speculate, attachAgentVm, attachAgentVmFromEnv, runTravelSpeculationDemo, createAgentVmRuntime, EffectGate, EffectJournal, TransactionCoordinator, SpeculationScheduler.
  • Dependencies: @hazeljs/​agent (peer). Opt-in via attachAgentVm(runtime) or AGENT_OS_AGENT_VM=1.
  • Backward compatible: Tools without effect decorators default to irreversible — existing agents behave identically until annotated.
  • Common mistakes: Forgetting @Compensate on @Reversible tools; speculating without budget slicing; expecting store-buffer mode without @Irreversible({ predict }); wiring setEffectGate by hand instead of attachAgentVm.

Why Agent VM exists

Problem in productionAgent VM answer
Agent holds a seat / lock, then loses — resource leaked@Reversible + journal → losers auto-compensate
Agent charged card on wrong branch of reasoning@Irreversible acts as barrier in speculation
Need to undo an entire run after bad outcome@Atomic + coordinator.undoRun(runId)
Explore 3 trip plans in parallel, pick best@Speculate({ branches: 3 }) — commit winner, roll back losers
readOnly: true tools already safeAuto-inferred as @Read() — free partial benefit

LangGraph forks state. CrewAI delegates tasks. Nobody forks side effects with automatic rollback — because nobody has an effect system at the tool layer.

Installation

npm install @hazeljs/​agent-vm @hazeljs/​agent

attachAgentVm builds the VM stack from the runtime (state manager + agent/tool resolvers) and wires runtime.setEffectGate(vm.effectGate). No behavior change until you attach.

import { attachAgentVm, attachAndBindAgentVm, getBoundAgentVm } from '@hazeljs/​agent-vm';

const vm = attachAgentVm(runtime, { barrierMode: 'converge' });

/​/​ Keep a WeakMap lookup for status /​ undo APIs
attachAndBindAgentVm(runtime);
const later = getBoundAgentVm(runtime);
await later?.coordinator.undoRun(runId);

Opt-in from env

Set AGENT_OS_AGENT_VM=1 in production or local flags. Returns undefined when disabled so existing agents stay unchanged.

import {
  attachAgentVmFromEnv,
  attachAgentVmStatusFromEnv,
  formatAgentVmStatusBoot,
  getBoundAgentVmStatus,
} from '@hazeljs/​agent-vm';

const status = attachAgentVmStatusFromEnv(runtime);
console.log(formatAgentVmStatusBoot(status));
/​/​ Agent VM: on · barrier=converge · quarantine=0

const bound = getBoundAgentVmStatus(runtime);
if (bound?.enabled) {
  await bound.vm.coordinator.undoRun(runId);
}
EnvDefaultMeaning
AGENT_OS_AGENT_VMunset (off)1 attaches Agent VM to the runtime
AGENT_OS_AGENT_VM_BARRIERconvergeconverge · abort · store-buffer
AGENT_OS_AGENT_VM_STORE_BUFFERunset (off)1 enables store-buffer for irreversible tools

attachAgentVmFromEnv also binds the bundle, so status/undo routes can call getBoundAgentVm / getBoundAgentVmStatus later.

Manual factory

Use createAgentVmRuntime only when you do not have an AgentRuntime (tests, custom executors):

import { ToolExecutor } from '@hazeljs/​agent';
import { createAgentVmRuntime } from '@hazeljs/​agent-vm';

const vm = createAgentVmRuntime({ stateManager, resolveAgentInstance, resolveTool });
const toolExecutor = new ToolExecutor();
toolExecutor.setEffectGate(vm.effectGate);

Effect lattice

From most to least permissive for speculation:

EffectDecoratorSpeculation-safeUndo
Pure@Pure()YesN/A — deterministic, cacheable
Read@Read()YesN/A — observes only
Reversible@Reversible({ compensate: 'method' })Yes@Compensate handler
Irreversible@Irreversible()No — barrierNever

Default: tools with no effect decorator → irreversible (safe default).

Inference: @Tool({ readOnly: true }) without an effect decorator → inferred as read.

graph TD
  Step["Agent step"] --> Spec{"@Speculate?"}
  Spec -->|no| Linear["Linear tool execution"]
  Spec -->|yes| Fork["Fork K branches"]
  Fork --> B1["Branch 1: CoW state + branch journal"]
  Fork --> B2["Branch 2"]
  Fork --> B3["Branch 3"]
  B1 --> Gate{"EffectGate"}
  B2 --> Gate
  B3 --> Gate
  Gate -->|"pure / read"| Run["Execute freely"]
  Gate -->|reversible| RunJ["Execute + journal inverse"]
  Gate -->|irreversible| Barrier["Barrier: converge / abort / defer"]
  Run --> Score["Score branches"]
  RunJ --> Score
  Barrier --> Score
  Score --> Commit["Commit winner"]
  Score --> Roll["Roll back losers: compensate newest-first"]

Decorator surface

import { Agent, Tool } from '@hazeljs/​agent';
import {
  Pure, Read, Reversible, Irreversible, Compensate, Speculate, Atomic,
  type EffectRecord,
} from '@hazeljs/​agent-vm';

@Agent({ name: 'TravelAgent' })
class TravelAgent {
  @Tool({ description: 'Search flights', readOnly: true })
  @Read()
  async searchFlights(input: { from: string; to: string }) {
    return flights.filter(f => f.from === input.from && f.to === input.to);
  }

  @Tool({ description: 'Hold a seat temporarily' })
  @Reversible({ compensate: 'holdSeat' })
  async holdSeat(input: { flightId: string }) {
    return store.hold(input.flightId);
  }

  @Compensate('holdSeat')
  async releaseHold(effect: EffectRecord<{ holdId: string }>) {
    store.release(effect.output.holdId);
  }

  @Tool({ description: 'Charge customer card' })
  @Irreversible()
  async chargeCard(input: { amount: number }) {
    return payment.charge(input.amount);
  }

  @Tool({ description: 'Score a flight option' })
  @Pure()
  async scoreOption(input: { price: number }) {
    return { score: 1 - input.price /​ 1000 };
  }

  @Speculate({ branches: 3, scorer: 'heuristic', prune: 'score' })
  async planTrip(request: string) {
    return request;
  }
}

@Compensate receives the journal entry (EffectRecord), not the original input — so compensation uses the tool's actual output (holdId), which is the only way real-world undo works.

Runtime stack

createAgentVmRuntime() (and attachAgentVm, which calls it) wires:

ComponentRole
EffectJournalAppend-only log of reversible tool executions
EffectGateEnforces lattice at ToolExecutor chokepoint; journals reversibles
TransactionCoordinatorReplays @Compensate inverses newest-first; quarantine on failure
BranchStateManagerCopy-on-write agent state — branches never mutate parent until commit
SpeculationSchedulerFork K branches, score, commit winner, roll back losers
BarrierHandlerConverge, abort-to-linear, or store-buffer for irreversible tools
const vm = attachAgentVm(runtime, {
  barrierMode: 'converge',
  emit: (event) => metrics.record(event.type, event.data),
});

Speculative execution

Fork K reasoning branches, run concurrently, score, commit the winner, automatically compensate losers.

Lab helper (no app glue required):

import { runTravelSpeculationDemo } from '@hazeljs/​agent-vm';

const result = await runTravelSpeculationDemo(3);
/​/​ or: runTravelSpeculationDemo({ branches: 3, sessionId: 'lab' })

console.log(result.winnerBranchId);
console.log(result.rolledBackBranches); /​/​ losers — holds released via @Compensate
console.log(result.activeHolds, result.releasedHolds);

Drive the scheduler yourself:

const runId = EffectGate.newRunId();
const parentCtx = runtime.getStateManager().createContext('travel-agent', sessionId, userGoal);

const result = await vm.scheduler.speculate(
  runId,
  parentCtx.executionId,
  {
    branches: 3,
    scorer: 'heuristic',       /​/​ or 'llm-judge' | 'custom'
    prune: 'score',
    concurrency: 3,
    barrierMode: 'converge',
  },
  async (branchId, branchIndex, branchBudget) => {
    const flight = flights[branchIndex];
    const hold = await agent.holdSeat({ flightId: flight.id });
    return { flightId: flight.id, holdId: hold.holdId, price: flight.price };
  },
  {
    agentId: 'travel-agent',
    sessionId,
    parentBudget: { maxCostUsd: 0.09, maxTokens: 9000 },
  }
);

Budget control: parent RunBudget is sliced evenly across branches. Use prune: 'score' to stop spending on branches that fall behind the leader.

Scorers:

ScorerUse
heuristicDemo / fallback — scores by output richness
llm-judgePass judgeFn to runtime factory for LLM-as-judge
customYour own (result) => number function

Transactional undo (@Atomic)

Undo an entire agent run — replay compensation handlers newest-first:

import { Atomic } from '@hazeljs/​agent-vm';

@Atomic({ autoUndoOnFailure: true })
async runSupportFlow(input: string) {
  return runtime.execute('support-agent', input);
}

const undo = await vm.coordinator.undoRun(runId);
console.log(`Compensated ${undo.compensated}, failed ${undo.failed}`);
console.log(`Quarantined: ${undo.quarantined}`);

Failed compensations emit COMPENSATION_FAILED events and land in a quarantine store — they never silently disappear.

Barriers — irreversible tools in speculative branches

When a branch hits an @Irreversible() tool:

ModeBehavior
converge (default)Pause branch; only the winning branch may execute the irreversible tool
abortFall back to linear execution for the rest of the run
store-bufferDefer intent, return @Irreversible({ predict }) output; drain on commit

Store-buffer is opt-in (enableStoreBuffer: true on attach, or AGENT_OS_AGENT_VM_STORE_BUFFER=1) and requires a predict function on the tool.

Effect journal & stores

import { EffectJournal, InMemoryJournalStore, FileJournalStore } from '@hazeljs/​agent-vm';

const journal = new EffectJournal(new InMemoryJournalStore());
const journal = new EffectJournal(new FileJournalStore('.​/data/​agent-vm-journal'));

Journal entries capture: tool name, input, output, effect kind, compensate method binding, branch/run IDs, status (committed | compensated | failed | deferred).

Events

AgentVmEventType for observability integration:

  • agent.vm.effect.journaled
  • agent.vm.compensation.started / completed / failed
  • agent.vm.speculation.started / branch.started / branch.completed / branch.pruned / committed / rolled_back
  • agent.vm.barrier.hit / converged / aborted
  • agent.vm.atomic.undo.started / completed

Pass emit to attachAgentVm(runtime, { emit }) or createAgentVmRuntime({ emit }).

Integration with Agent OS

Agent VM complements — does not replace — Agent OS primitives:

Agent OSAgent VM
@hazeljs/​agent-gatekeeperauthorize before executionEffect types — classify side effects for speculation/undo
@hazeljs/​saga — business saga compensationTool-level compensation via @Compensate
AgentGraph — fork stateSpeculationScheduler — fork side effects with rollback
Durable HITL — human approvesBarriers — irreversible tools force branch convergence
RunBudgetTracker — hard stopBudget slicing across speculative branches

Recommended stack for production agents that mutate external systems:

@hazeljs/​agent  →  @hazeljs/​agent-vm  →  @hazeljs/​agent-gatekeeper  →  @hazeljs/​skillgate
     runtime          attach + speculate         authorize                 governed REST

Recipe: Speculative trip planning with reversible holds

Three branches explore different flights. Two hold seats; one wins; the other two holds are provably released.

import { runTravelSpeculationDemo } from '@hazeljs/​agent-vm';

const result = await runTravelSpeculationDemo(3);

expect(result.activeHolds).toBe(1);
expect(result.releasedHolds).toBe(2);
expect(result.rolledBackBranches).toHaveLength(2);

The helper lives in the package (src/​demo/​travel-agent.demo.ts) — apps should not copy speculation glue.

Recipe: Attach Agent VM to AgentRuntime

import { AgentRuntime } from '@hazeljs/​agent';
import {
  attachAgentVmFromEnv,
  formatAgentVmBoot,
  getBoundAgentVm,
} from '@hazeljs/​agent-vm';

const runtime = new AgentRuntime({ /​* llm, tools, … */​ });
const vm = attachAgentVmFromEnv(runtime);
console.log(formatAgentVmBoot(vm, { barrierMode: 'converge' }));

/​/​ Later: status /​ undo
const bound = getBoundAgentVm(runtime);
await bound?.coordinator.undoRun(runId);

Every reversible tool execution is journaled automatically after success.

API reference

ExportDescription
@Pure()No I/O, deterministic
@Read()Read-only external observation
@Reversible({ compensate })Mutates with paired inverse
@Irreversible({ predict? })Barrier — cannot undo
@Compensate(forTool)Inverse handler — receives EffectRecord
@Atomic({ autoUndoOnFailure? })Marks run as transactionally undo-able
@Speculate({ branches, scorer, prune, concurrency, barrierMode })Multi-branch metadata
attachAgentVm(runtime, options?)Build stack + setEffectGate
attachAndBindAgentVm(runtime)Attach + WeakMap bind for later lookup
getBoundAgentVm(runtime)Read bound bundle
attachAgentVmFromEnv(runtime)Opt-in when AGENT_OS_AGENT_VM=1
attachAgentVmStatusFromEnv(runtime)Same, plus { enabled, vm, barrierMode }
getBoundAgentVmStatus(runtime)Bound status for HTTP / boot logs
formatAgentVmBoot / formatAgentVmStatusBootOne-line boot status
runTravelSpeculationDemo(n | options)Package lab: K holds, winner commits, losers compensate
createAgentVmRuntime(options)Low-level factory when you have no AgentRuntime
EffectGateLattice enforcement + journaling hooks
EffectJournalAppend-only effect log
TransactionCoordinatorundoRun, rollbackBranch
SpeculationSchedulerspeculate(runId, parentExecId, config, branchFn, ctx?)
BranchStateManagerCoW fork/commit/discard
BarrierHandlerIrreversible tool modes
sliceBudgetAcrossBranchesBudget splitting helper
HeuristicScorer, LlmJudgeScorer, CustomScorerBranch scoring

Comparison

CapabilityLangGraphCrewAIAutoGenHazelJS Agent VM
Fork agent stateYesPartialPartialYes (BranchStateManager)
Fork side effects with rollbackNoNoNoYes
Declare tool effect classNoNoNoYes
Transactional undo of tool chainNoNoNoYes
Irreversible tool barriersNoNoNoYes
TypeScript-native decoratorsNoNoNoYes

Next steps

  1. Annotate mutation tools with @Reversible + @Compensate
  2. Mark payments/emails/deletes with @Irreversible
  3. Call attachAgentVm(runtime) or set AGENT_OS_AGENT_VM=1
  4. Try runTravelSpeculationDemo(3) locally
  5. Add @Speculate on high-ambiguity planning steps
  6. Monitor agent.vm.* events and quarantine queue in production