Adapters
The /adapters injection boundary -- the one Substrate primitive, the restart-survival invariant, the gateway-ingress and cursor toolkit, record/replay, and the port contracts a custom backend implements.
Adapters
@openprose/reactor/adapters is the injection boundary: the seam where a
reactor's I/O lives. Everything above it -- the reconciler, the canonicalizer,
the receipt chain -- is a pure function over the ports this subpath defines. A
deployment swaps the backends here without touching the engine, and the
front door facade reaches in for you on the common paths.
This page is the reference for what /adapters exports and, more importantly,
the contracts a custom backend must honor to stay correct. The headline is one
record:
import { fileSystemSubstrate } from "@openprose/reactor/adapters";The one Substrate primitive
A reactor keeps two durable things: its truth (the world-model) and its
memory of what it already decided (the receipt trail). Substrate is the
single record that answers "where does a reactor keep both" -- one shape, four
fields:
interface Substrate {
readonly clock: ClockAdapter; // the only time source
readonly storage: StorageAdapter; // the receipt ledger's append-only trail
readonly worldModel: WorldModelStore; // the truth the render commits through
readonly ledger: MutableReceiptLedger; // derived from `storage` (see below)
}Two named factories build it correctly so you do not hand-wire the four fields:
fileSystemSubstrate({ directory })-- the durable substrate. A system clock, a filesystem storage adapter (the receipt trail at<directory>/receipts.json), a durable ledger re-derived from that storage, and a filesystem world-model store under<directory>/world-models/. This is the canonical layout the CLI and DevTools fixtures share, so a substrate built here and a state-dir the CLI populated re-open the same durable trail and truth.inMemorySubstrate()-- the ephemeral substrate for tests and replay. The same clock, an in-memory storage adapter, a ledger re-derived from that in-memory storage (identical re-derivation semantics, just no disk), and an in-memory world-model store. Nothing persists; a freshinMemorySubstrate()is empty.
The constructors (mountDag / createReactor / runProject) accept a
{ substrate }, and the substrate is a strict superset of the older à-la-carte
fields -- no backend was removed. The facade builds one for you from
{ directory }.
The restart-survival invariant
This is the load-bearing reason the durable factory exists, and the one thing a custom backend must not get wrong.
For a durable substrate, the ledger MUST be derived from the same storage
adapter -- createFileSystemReceiptLedger({ storage }) over the exact storage
the substrate also exposes. The durable ledger re-derives every node's last
receipt from that storage's append-only trail at construction. So re-opening the
same directory re-opens the full prior memory, and the boot sweep memo-skips
the unchanged nodes instead of re-rendering them.
fileSystemSubstrate bakes this in -- it builds the storage adapter and then
builds the ledger over that very adapter, so a consumer never has to remember to
wire it. "The ledger is the source of truth": a restart that re-opens the same
directory resumes the prior memory, which is what makes "cost scales with
surprise, not wall-clock time" survive a process restart.
The storage-only spread idiom
You may need to swap one field -- say a custom storage adapter -- while keeping the rest correct. The blessed override is a spread over the factory, never a hand-built record:
import { createReactor } from "@openprose/reactor";
import { fileSystemSubstrate } from "@openprose/reactor/adapters";
const r = createReactor({
substrate: { ...fileSystemSubstrate({ directory }), storage: myStorage },
topology,
mounts,
});Spreading the factory keeps the other three fields built-right. Supplying a
divergent in-memory ledger alongside durable storage is the explicit opt-out,
not an accident -- if you spread a different ledger over durable storage, you
have deliberately broken restart-survival. The factory makes the correct path
the short path; the override makes the unusual path visible.
Gateway ingress and the idempotency cursor
/adapters ships the toolkit for turning an external arrival into a receipt at
the system's edge, without ever letting a re-delivered arrival manufacture a
second one.
The connector is the one impure seam -- the actual I/O against a source:
import { createPollConnectorAdapter } from "@openprose/reactor/adapters";
// `fetch` performs the real read (HTTP GET, queue drain, file read). A test
// supplies a deterministic stub. Either way the adapter stays a pure function
// over injected I/O.
const connector = createPollConnectorAdapter((request) => fetchSource(request));pollGateway (and its async sibling pollGatewayAsync) drives the edge. For
each new arrival it stages the item into the gateway node's upstream truth,
marks the cursor, then wakes the node:
import {
pollGateway,
createIdempotencyCursor,
} from "@openprose/reactor/adapters";
const cursor = createIdempotencyCursor();
const result = pollGateway(dag, {
connector,
source_id: "inbox",
node: "gateway",
extract: (payload) => toArrivals(payload), // -> readonly GatewayArrival[]
cursor,
stage: (arrival) => appendToInbox(arrival.item),
});
result.ingested_ids; // arrivals past the cursor that drove a wake this poll
result.skipped_ids; // arrivals already in the cursor -- no wake, no receiptThe cursor is what dedups before the edge. Each GatewayArrival carries a
stable id (a message id, an event id, a content hash) -- the same logical
arrival yields the same id across polls and redeliveries. An already-seen
(source, id) pair is dropped, so it never produces a second receipt. The order
of arrivals is the delivery order, so the cursor advances monotonically.
The cursor is durable. It round-trips through the storage registry as a plain JSON-able snapshot, so a restart resumes without re-ingesting the backlog:
import {
loadIdempotencyCursor,
cursorRegistryPatch,
} from "@openprose/reactor/adapters";
// At boot: rehydrate from the registry the storage adapter persisted.
const cursor = loadIdempotencyCursor(storage.readRegistry());
// After a poll: project the cursor's snapshot into a registry patch to persist.
storage.writeRegistry({ ...storage.readRegistry(), ...cursorRegistryPatch(cursor) });The staging order is deliberate -- stage, mark, then wake. Marking before
the wake means a throw mid-render does not re-stage on the next poll: the arrival
is durably consumed once it is staged, and the gateway's render re-runs against
the staged truth on a later wake if it failed. A single payload that lists the
same idempotency key twice is a source bug the cursor cannot disambiguate, so
pollGateway fails loudly rather than silently ingesting one and dropping the
other.
Record and replay
Two recording adapters make a run reproducible without a live provider -- the mechanism the keyless DevTools replay path rests on.
createRecordReplayModelGatewayAdapter({ records })replays a captured sequence of model calls. Eachinvokeis matched against the next record's request (canonical-JSON equality); a mismatch throws with the record id, so a drifted run is caught at the exact diverging call rather than silently producing a different truth. The adapter exposescalls()andremaining()for inspection.createPassthroughAgentSdkAdapter({ launch?, sandbox? })is the agent-SDK passthrough that records every launch and sandbox run. Without handlers it echoes the request payload back;createNullAgentSdkAdapter(payload)is the inert variant that returns a fixed payload. Both fold the sandbox path in (the architecture folds sandbox execution into the agent-SDK port).
Every leaf adapter clones its request in and payload out through canonical JSON, so an adapter cannot be mutated by a caller and a payload is always a defensive copy.
The port contracts a backend implements
A custom backend implements one of the trimmed v1 ports. These are the contracts
the harness is a pure function over -- the short names are the headline
vocabulary Substrate uses; the Reactor*-prefixed originals are the
deprecated aliases, kept reachable from both /adapters and
/internals (nothing was removed).
| Port (short name) | Reactor* original | Responsibility |
|---|---|---|
ClockAdapter | ReactorClockAdapter | the only time source -- now(): string |
StorageAdapter | ReactorStorageAdapter | the receipt ledger's append-only trail plus the shrunk registry |
WorldModelStore | ReactorWorldModelStore | read-by-reference, commit-and-fingerprint, content-addressed versioning |
| -- | ReactorModelGatewayAdapter | render / compile-step invocation -- the model call seam |
| -- | ReactorConnectorAdapter | external evidence sources (gateways) |
A few contracts are worth stating plainly, because they are where a custom backend tends to drift:
StorageAdapteris append-and-list over receipts plus read/write of a shrunk registry. The registry is a dumb canonical-JSON key/value -- it carries the topology world-model and self-driven schedule as opaque blobs, nothing more. Keep it byte-stable across equal states (sorted, canonical) so the durable snapshot is deterministic.WorldModelStorehands the render a queryable reference to a node's prior truth (never pre-stuffed into context), andcommitproduces the deterministic canonical serialization, content-addresses it, and returns the new version plus the canonicalizer-computed fingerprints. Only thepublishedworkspace is fingerprinted; the render's privateworkspaceis not.ReactorModelGatewayAdapterreports usage as a{ provider, model, tokens }triple -- the cost-bearing half of the receipt. It does not know the wake source, sosurprise_causeis supplied by the reconciler, not the gateway.
The reference backends for these ports also live on /adapters:
createSystemClockAdapter / createFixedClockAdapter,
createFileSystemStorageAdapter / createMemoryStorageAdapter,
FileSystemWorldModelStore / InMemoryWorldModelStore (and their create*
helpers), FileSystemReceiptLedger / InMemoryReceiptLedger, and the
createStaticConnectorAdapter for an inert in-memory source.
Honest status: the v1 port surface is deliberately trimmed. The signer port is
deferred to the crypto byte-hash milestone -- the only honest v1 signature is the
null signature, so the receipt chain is tamper-evident and chain-consistent, not
a cryptographic byte hash. eventSink is dropped on purpose: telemetry is read
off the ledger, not a separate sink. sandbox is folded into the agent-SDK port.
None of this is a gap to paper over; it is the surface as shipped.
Where to go next
Internals
The engine room: the reconciler-construction spine, the deep domain shapes, and the receipt-proof helpers behind the ports above.
Connectors and sandbox (CLI)
How the CLI wires connectors and the docker sandbox over this same ingress toolkit.
Continuity and ingestion
Why every wake is a receipt, and how gateways and idempotency cursors keep a re-delivered arrival from counting twice.
The front door
The reactor() facade that builds a substrate for you, and the one typed handle it hands back.
The /agents escape hatch
The peer-dep-isolated @openai/agents passthrough -- the layered RenderOptions (Tier A sugar, Tier B verbatim passthrough, Tier C backstop), the RenderBackend injection seam, and the compile-session surface. Every render knob is reachable, with zero capability loss.
The offline boundary
The /run and /run/types entrypoints -- compileProject and runProject, the model-bearing run phase that stays off the keyless front door, plus the type-only mirror that types the handle without crossing the boundary.