HazelJS Agent VM Package
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 viaattachAgentVm(runtime)orAGENT_OS_AGENT_VM=1. - Backward compatible: Tools without effect decorators default to
irreversible— existing agents behave identically until annotated. - Common mistakes: Forgetting
@Compensateon@Reversibletools; speculating without budget slicing; expecting store-buffer mode without@Irreversible({ predict }); wiringsetEffectGateby hand instead ofattachAgentVm.
Why Agent VM exists
| Problem in production | Agent 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 safe | Auto-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
Attach to AgentRuntime (recommended)
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);
}
| Env | Default | Meaning |
|---|---|---|
AGENT_OS_AGENT_VM | unset (off) | 1 attaches Agent VM to the runtime |
AGENT_OS_AGENT_VM_BARRIER | converge | converge · abort · store-buffer |
AGENT_OS_AGENT_VM_STORE_BUFFER | unset (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:
| Effect | Decorator | Speculation-safe | Undo |
|---|---|---|---|
| Pure | @Pure() | Yes | N/A — deterministic, cacheable |
| Read | @Read() | Yes | N/A — observes only |
| Reversible | @Reversible({ compensate: 'method' }) | Yes | @Compensate handler |
| Irreversible | @Irreversible() | No — barrier | Never |
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:
| Component | Role |
|---|---|
EffectJournal | Append-only log of reversible tool executions |
EffectGate | Enforces lattice at ToolExecutor chokepoint; journals reversibles |
TransactionCoordinator | Replays @Compensate inverses newest-first; quarantine on failure |
BranchStateManager | Copy-on-write agent state — branches never mutate parent until commit |
SpeculationScheduler | Fork K branches, score, commit winner, roll back losers |
BarrierHandler | Converge, 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:
| Scorer | Use |
|---|---|
heuristic | Demo / fallback — scores by output richness |
llm-judge | Pass judgeFn to runtime factory for LLM-as-judge |
custom | Your 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:
| Mode | Behavior |
|---|---|
converge (default) | Pause branch; only the winning branch may execute the irreversible tool |
abort | Fall back to linear execution for the rest of the run |
store-buffer | Defer 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.journaledagent.vm.compensation.started/completed/failedagent.vm.speculation.started/branch.started/branch.completed/branch.pruned/committed/rolled_backagent.vm.barrier.hit/converged/abortedagent.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 OS | Agent VM |
|---|---|
@hazeljs/agent-gatekeeper — authorize before execution | Effect types — classify side effects for speculation/undo |
@hazeljs/saga — business saga compensation | Tool-level compensation via @Compensate |
AgentGraph — fork state | SpeculationScheduler — fork side effects with rollback |
| Durable HITL — human approves | Barriers — irreversible tools force branch convergence |
RunBudgetTracker — hard stop | Budget 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
| Export | Description |
|---|---|
@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 / formatAgentVmStatusBoot | One-line boot status |
runTravelSpeculationDemo(n | options) | Package lab: K holds, winner commits, losers compensate |
createAgentVmRuntime(options) | Low-level factory when you have no AgentRuntime |
EffectGate | Lattice enforcement + journaling hooks |
EffectJournal | Append-only effect log |
TransactionCoordinator | undoRun, rollbackBranch |
SpeculationScheduler | speculate(runId, parentExecId, config, branchFn, ctx?) |
BranchStateManager | CoW fork/commit/discard |
BarrierHandler | Irreversible tool modes |
sliceBudgetAcrossBranches | Budget splitting helper |
HeuristicScorer, LlmJudgeScorer, CustomScorer | Branch scoring |
Comparison
| Capability | LangGraph | CrewAI | AutoGen | HazelJS Agent VM |
|---|---|---|---|---|
| Fork agent state | Yes | Partial | Partial | Yes (BranchStateManager) |
| Fork side effects with rollback | No | No | No | Yes |
| Declare tool effect class | No | No | No | Yes |
| Transactional undo of tool chain | No | No | No | Yes |
| Irreversible tool barriers | No | No | No | Yes |
| TypeScript-native decorators | No | No | No | Yes |
Related resources
- Agent Package — Agent OS kernel and
ToolExecutor - Agent Gatekeeper — authorize before execution
- Saga Package — distributed business sagas (complementary)
- Agent OS guide — durable runs, HITL, DNA
- Testing Package —
describeAgentregression suites - Compare: LangGraph — state forks vs effect forks
Next steps
- Annotate mutation tools with
@Reversible+@Compensate - Mark payments/emails/deletes with
@Irreversible - Call
attachAgentVm(runtime)or setAGENT_OS_AGENT_VM=1 - Try
runTravelSpeculationDemo(3)locally - Add
@Speculateon high-ambiguity planning steps - Monitor
agent.vm.*events and quarantine queue in production