LoopEngineBETA

Docs

Documentation

How LoopEngine works, from scaffold to durable approvals — agents, adapters, permission rules, and abilities.

Session Persistence

core/session-store.ts's withSession(sessionId, fn) is the load-run-persist contract every adapter goes through — never raw load/save — locking so two concurrent requests for the same session can't race on a read-modify-write. Storage itself is core/sessionknit.ts's SessionKnit: a durable, append-only, parent-linked log per session, not a flat blob rewritten whole on every turn.

What "one conversation" means: sessionIdFor

By default, adapters/http.ts derives the session key from a plain client-supplied sessionId field in the request body — or generates a fresh one if none was sent and the agent has no sessionIdFor of its own. An agent that needs a stable identity instead of an ephemeral, client-chosen key overrides AgentConfig.sessionIdFor, and it wins outright whenever it's present:

import { createHash } from 'node:crypto'
import type { AgentConfig } from 'loopengine'

// A customer's email is what "one conversation" means for this agent,
// so the mapping lives here rather than being hardcoded in the adapter
// for every agent it might ever route to.
function sessionIdFor(body: Record<string, unknown>): string | undefined {
  const customerEmail = typeof body.customerEmail === 'string' ? body.customerEmail.trim() : ''
  if (!customerEmail) return undefined
  const conversationId = typeof body.conversationId === 'string' ? body.conversationId : 'default'
  const hash = createHash('sha256').update(customerEmail.toLowerCase()).digest('hex').slice(0, 24)
  return `customer-${hash}-${conversationId}`
}

export const config: AgentConfig = {
  // ...
  sessionIdFor,
}

(agents/customer-service/index.ts's real version — hashed so a raw email never ends up in a Redis key or a filename.) Returning undefined here is a genuine validation failure — no customerEmail in the body — not "start an anonymous session"; that auto-generated fallback only applies to the library's own agent-agnostic default.

Every resolved id is namespaced before it ever reaches storage — ${tenant}:${environment}:${agentName}:${rawSessionId} — so two agents, tenants, or environments whose sessionIdFor happens to produce the same raw value never collide on the same underlying log.

How the durable session actually works

run-agent.ts pushes a whole turn as two messages: the model's tool_use-laden assistant reply first, then one user message bundling every tool_result once they've all settled. If the process dies in between, that dangling assistant message is the last thing durably on disk. The next withSession call detects it — an assistant message with an unresolved tool_use block — and injects a plain continuation message on resume instead of silently resending an incomplete turn as if it were clean.

Two backends, chosen by createSessionStore() from the environment:

FileSessionStore RedisSessionStore
Storage One JSONL entry log per session, under .sessions/ One Redis list — one RPUSH per appended entry
Locking An in-process KeyedMutex A real distributed Redis lock
Picked when REDIS_URL unset — local dev REDIS_URL set — more than one server instance

Writes are debounced for the file backend; a no-op for Redis, since an RPUSH is already durable per write. Reading a session's history back out (getHistory) is deliberately not held behind that same lock — it has to keep working while a turn is paused on a pending approval or question, not only once the turn has finished.