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.