skills/runtime/references/agent-runners-in-memory.md
InMemoryAgentRunner — default ephemeral runner. Thread state lives in a bounded, process-global store shared by every runner instance in the process.
// packages/runtime/src/v2/runtime/runner/in-memory.ts
export const ɵGLOBAL_STORE = new ɵBoundedThreadStore(ɵINMEMORY_DEFAULTS);
ɵBoundedThreadStore owns the Map<threadId, InMemoryEventStore>, LRU ordering, byte accounting, and eviction. The runner keeps all streaming logic and delegates storage to the store. The ɵ prefix marks internal API — exported for tests, not part of the public surface.
One InMemoryEventStore per threadId. Each store tracks:
subject: ReplaySubject<BaseEvent> | null — current consumers; released on run completionisRunning: boolean — gate for the "Thread already running" throwcurrentRunId: string | nullhistoricRuns: HistoricRun[] — completed runs (events only; see snapshot note below)messagesSnapshot: Message[] — the thread's latest non-empty message snapshot, held at the THREAD level so run-cap eviction can never drop itagent: AbstractAgent | null — the instance that owns the active runrunSubject, currentEvents, stopRequestedThree limits, whichever trips first. Defaults in ɵINMEMORY_DEFAULTS:
| Option | Default | Enforcement |
|---|---|---|
maxThreads | 1000 | LRU eviction of the least-recently-used thread |
maxRunsPerThread | 100 | FIFO drop of oldest runs; Infinity or 0 disables |
maxBytes | 512 MiB | Approximate total across all threads; evicts OTHER LRU threads |
new InMemoryAgentRunner({
maxThreads: 200,
maxRunsPerThread: 50,
maxBytes: 128 * 1024 ** 2,
});
Invariants worth knowing before touching this code:
isRunning or stopRequested is set. stop() flips isRunning false immediately but the run finalizes asynchronously; evicting in that window would make the pending appendRun silently drop history.maxBytes only bounds committed history. A single in-flight run's buffered events are not counted until the run completes, so it does not bound one runaway run mid-stream.maxBytes evicts other threads and never self-evicts the just-appended thread — it is a cross-thread ceiling, not a per-thread cap. A single dominant thread is bounded by maxRunsPerThread.JSON.stringify().length estimate, not exact heap bytes.maxThreads count or maxBytes ceiling) drops the entire LRU thread — it stops appearing in GET /threads. Per-thread maxRunsPerThread trimming drops only a thread's oldest runs' events, keeping the thread visible with its original createdAt and its thread-level messagesSnapshot. The latch fires once per store (not once per eviction), reset only by clearThreads()/clear(), so a hot thread trimming on every append logs a single line and every later eviction is silent until a clear.onConcurrentRun is per-runner (unlike the limits, which are process-global):
"throw" (default) — a second run() on a live thread throws Error("Thread already running")."supersede" — aborts the in-flight run (same path as stop()) and starts the new one. The superseded run's teardown is guarded on store.currentRunId === request.input.runId (so it cannot push history under the new run's id or reset the new run's state) and on store.subject === nextSubject (so releasing its ReplaySubject cannot null out the live run's subject).run({ threadId, agent, input }) — sharedStore.getOrCreate(threadId) (may evict other threads), then throw or supersede per onConcurrentRun. Create ReplaySubjects, run the agent, push events into the subjects and currentEvents, mark isRunning.sharedStore.appendRun(...) with the compacted events (which enforces the run cap and byte ceiling), clear isRunning / currentRunId / agent, and release store.subject so the infinite ReplaySubject buffer becomes collectable. History is rebuilt from historicRuns afterwards.connect({ threadId }) — replays compacted historicRuns, then bridges the live subject while isRunning || stopRequested.stop({ threadId }) — sets stopRequested = true, aborts the agent; teardown runs in the run's catch.Limits reconfigure the shared store, so the last-constructed runner wins for all in-memory threads. A second runner passing limits that differ from an already-customized store logs a one-time clobber warning. Passing only onConcurrentRun leaves the limits untouched.
new InMemoryAgentRunner() shares the same store, so use a fresh threadId per test or call runner.clearThreads() (which resets the map, byte total, and eviction warn latch) between tests. Tests that customize limits must restore them: new InMemoryAgentRunner(ɵINMEMORY_DEFAULTS) in an afterEach — a no-arg construction is inert and will NOT restore defaults.Source: packages/runtime/src/v2/runtime/runner/in-memory.ts.