js/ai/session

js/ai/session.ts

fino:ai/session — durable agent runs with session-owned history persistence.

Session runs an already-configured Agent against an AtomicStore. Use it when an agent run must survive process restarts, pause for external input, continue a thread across multiple requests, or fork from an existing conversation state.

Storage model

MessageHistory is an immutable in-memory graph. The exported conversation and run functions are thin record codecs over fino:store; ACP and ordinary agent conversations use the same namespace and layout. Atomic commits store new history graph nodes, the current run state, and the thread head together.

Session.start() creates a new run in the session thread, resume() injects external input into a suspended run, and fork() creates a new thread from the current history revision. Session.resume() and Session.resumeSuspended() are for stateless adapters that need to continue from persisted state.

Suspension and approval

A run pauses in two ways: code inside a step throws SuspendSignal (waiting for arbitrary external input), or a tool marked requiresApproval produces a tool-approval request. Both persist a single-use resume token in RunState.suspendedOn. Plain suspensions continue through resume(token, value); approval suspensions continue through approveTool() / rejectTool(), or their static *Suspended counterparts after a process restart.

Every step is committed through the store's atomic capability before the next one begins, so a crash never loses more than the in-flight step. The live run state is observable through watch() for reactive consumers. An optional AgentMemoryController feeds cross-session semantic hits into new runs without storing conversation transcripts.

import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';

const store = await sqliteStore({ path: './runs.db' });
const sess = session({
  store,
  agent: agent({ model: openai({ model: 'gpt-4o' }) }),
  threadId: 'customer-123',
});

const first = await sess.start('Start a support conversation.');
if (first.status === 'suspended') {
  await sess.resume(first.state.suspendedOn!.token, 'human input');
}

Types

type RunStatus = 'running' | 'suspended' | 'done' | 'error' | 'cancelled'

Durable lifecycle state for a session run.

running is what a checkpoint says while a step is in flight — reading it back from a store after a crash means the process died mid-drive, and Session.resume() can re-drive from there. Driving calls only ever return suspended or done results; failures and aborts commit an error or cancelled checkpoint and then throw. suspended runs carry a single-use resume token in RunState.suspendedOn.

type ToolApprovalDecision = | { approved: true; approval?: unknown } | { approved: false; reason?: string }

Decision supplied when resuming a tool approval suspension.

An approved decision may carry an approval value that is forwarded to the tool's execution context. A rejection skips the tool entirely and records reason (defaulting to 'not approved') as an error tool result visible to the model, which then continues the conversation.

Interfaces

interface SuspendReason {

Suspension metadata persisted with a paused run.

Minted whenever a run suspends, and cleared when the run continues. For tool-approval suspensions payload is the pending ToolApprovalRequest (tool name, call id, arguments, risk); for SuspendSignal suspensions it is whatever payload the signal carried.

const r = await sess.start('delete the account for acct_1');
if (r.status === 'suspended') {
  console.log(r.state.suspendedOn!.reason);
  notifyOperator(r.runId, r.state.suspendedOn!.token);
}

Properties

token: string

Single-use resume token. Pass it to resume(), approveTool(), or rejectTool(); it is invalidated as soon as the run continues.

reason?: string

Human-readable explanation of why the run paused.

payload?: unknown

Structured data describing what the run is waiting for.

interface RunState {

Durable checkpoint state for one run.

This is the record the session codec persists after every step. Conversation content never lives here — historyRevisionId points into the immutable history graph instead, which keeps checkpoints small and lets many runs share one graph.

const state = await store.loadRun(runId);
if (state?.status === 'suspended') {
  console.log(`step ${state.stepIndex}, waiting on: ${state.suspendedOn?.reason}`);
}

Properties

runId: string

Unique identifier of this run.

threadId: string

Thread the run belongs to.

status: RunStatus

Current lifecycle state of the run.

stepIndex: number

Number of agent steps completed so far.

usage: Usage

Token usage accumulated across all steps of the run.

cost?: number

Accumulated cost reported by the agent, when the model prices requests.

historyRevisionId?: string

Head of the immutable history graph as of the last committed step.

suspendedOn?: SuspendReason

Suspension metadata; present only while status is 'suspended'.

scratch: Record<string, unknown>

Store-compatible structured-cloneable bag for application bookkeeping carried across checkpoints and restarts.

result?: unknown

Final assistant text once the run reaches 'done'.

error?: { message: string; stack?: string }

Failure captured when the run reached 'error'.

interface ThreadState {

Durable pointer for one conversation thread.

A thread is a named line of conversation. Its historyRevisionId is the head of the immutable history graph and advances with every committed step; starting a new run on the same threadId continues from this head, which is how a conversation spans multiple runs and process lifetimes.

const thread = await loadConversationThread(store, 'customer-123');
if (thread?.historyRevisionId) {
  const history = await loadConversationHistory(store, thread.historyRevisionId);
  console.log(history?.render().length, 'messages so far');
}

Properties

threadId: string

Unique identifier of the thread.

historyRevisionId?: string

Current head revision of the thread's history graph; absent until the first commit.

createdAt: number

Creation time in milliseconds since the epoch.

updatedAt: number

Time of the last commit in milliseconds since the epoch.

storeVersion?: string

Opaque backing-store version assigned after every successful commit. It is absent only on a thread value that has not been persisted yet.

metadata?: Record<string, unknown>

Store-compatible structured-cloneable conversation metadata owned by the caller.

The store treats this as opaque data and commits it atomically with the history head. Protocol adapters can therefore retain their own session state without introducing a parallel persistence abstraction.

interface ConversationCommitOptions {

Values atomically committed by commitConversationThread().

Properties

thread: ThreadState

New thread head and caller-owned metadata.

history: MessageHistory

Immutable history graph whose head the thread references.

expectedStoreVersion: string | null

Store version returned by loadConversationThread(), or null for creation.

baseRevisionId?: string

Existing graph head; only later history nodes are written when supplied.

interface AgentSessionCommitOptions {

Values atomically committed by commitAgentSession().

Properties

run: RunState

Run checkpoint written with the thread head.

thread: ThreadState

New thread head.

history: MessageHistory

Immutable history graph referenced by both records.

expectedThreadRevisionId: string | null

History head observed before this commit, or null for a new thread.

baseRevisionId?: string

Existing graph head; only later history nodes are written when supplied.

interface RunResult {

Result returned by session start, resume, and fork operations.

Driving calls resolve with 'suspended' (the run paused; the resume token is in state.suspendedOn) or 'done' (the agent finished; text carries the last assistant text). They never resolve with 'error' — failures commit an error checkpoint and then throw. 'done' and 'cancelled' also appear when Session.resume() is pointed at an already-terminal run.

const r = await sess.start('What changed in the last release?');
if (r.status === 'done') console.log(r.text);
else console.log('paused:', r.state.suspendedOn?.reason);

Properties

runId: string

Identifier of the run; use it with the static Session.resume* helpers.

status: RunStatus

Lifecycle state the run settled in for this call.

state: RunState

The run's checkpoint as of the returned status.

text?: string

Last assistant text, present when the run completed.

interface SessionOptions {

Options for creating a Session.

import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { memoryStore } from 'fino:store';
import { session } from 'fino:ai/session';

const sess = session({
  store: memoryStore(),
  agent: agent({ model: openai({ model: 'gpt-4o' }) }),
  threadId: 'customer-123',
  onCheckpoint: (s) => console.log(s.status, 'at step', s.stepIndex),
});

Properties

store: AtomicStore

Durable store that receives every checkpoint.

agent: Agent

Configured agent whose step loop the session drives.

memory?: AgentMemoryController

Optional long-term semantic memory. On start() matching hits are folded in ahead of the input. Conversation messages are never written to memory; use the opt-in memory creation tool or call remember() explicitly.

threadId?: string

Thread to continue. Defaults to a freshly generated id, i.e. a new conversation.

onCheckpoint?: (s: RunState) => void

Called after each committed step with the new run state.

Classes

class ConversationConflictError extends Error {

Raised when a conversation thread changes after a caller loads it.

Properties

threadId: string

Thread whose optimistic store version changed.

expectedVersion: string | null

Store version observed by the caller, or null for creation.

actualVersion: string | null

Current store version, or null when the thread does not exist.

Constructors

constructor(threadId: string, expectedVersion: string | null, actualVersion: string | null)

Create a typed conversation-store conflict.

class SessionConflictError extends Error {

Raised when a durable thread advances after a caller read its head.

The failed commit writes nothing. Callers may reload and retry, reject the overlapping turn, or explicitly fork from the revision they originally read.

Properties

threadId: string

Thread whose optimistic concurrency check failed.

expectedRevisionId: string | null

Revision the caller expected, or null for a new thread.

actualRevisionId: string | null

Revision currently stored, or null when the thread does not exist.

Constructors

constructor(threadId: string, expectedRevisionId: string | null, actualRevisionId: string | null)

Create a typed thread-head conflict.

class Session {

Durable runner for an agent thread.

A Session binds an Agent to an AtomicStore and one conversation thread. Each start() creates a run; the session drives the agent one step at a time, committing a checkpoint after every step, and returns when the run completes or suspends. A single driving call may take at most 100 steps before failing with a step-count error.

Instances hold the latest RunState in memory (state, watch()), but a run does not depend on its instance surviving: the static resume(), resumeSuspended(), approveSuspended(), and rejectSuspended() helpers rebuild everything from the store, which is what stateless adapters such as HTTP handlers should use.

Construct sessions with the session() factory — the constructor is private.

import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';

const store = await sqliteStore({ path: './runs.db' });
const sess = session({
  store,
  agent: agent({ model: openai({ model: 'gpt-4o' }) }),
  threadId: 'ticket-42',
});

const r = await sess.start('Summarize the open issue.');
if (r.status === 'suspended') {
  const token = r.state.suspendedOn!.token;
  const finished = await sess.resume(token, 'here is the missing detail');
  console.log(finished.text);
} else {
  console.log(r.text);
}

Getters

get state(): RunState | undefined

Latest run state held by this instance, or undefined before a run has been started or loaded.

Methods

watch(): ReadonlySignal<RunState | undefined>

Watch the latest run state held by this session instance.

The signal is undefined until a run is started or loaded. Once a run exists, it retains the latest checkpoint or terminal state for reactive UI consumers in this realm.

sess.watch().subscribe((state) => {
  if (state) render(`${state.status} — step ${state.stepIndex}`);
});
await sess.start('go');
async start( input: string | { messages: ModelMessage[] }, opts?: { runId?: string; signal?: AbortSignal }, ): Promise<RunResult>

Start a new run in this session thread.

Input is a plain user string or pre-built messages. If the thread already has committed history, the new run continues from its head — this is how a conversation carries across runs, restarts, and requests. When the session has an AgentMemoryController, semantic hits are folded in ahead of the input and the selection id is retained in state.scratch for a later eval outcome. Conversation history remains entirely separate.

Resolves with a 'done' or 'suspended' result. Agent errors, aborts via opts.signal, and exceeding the 100-step budget commit the matching error/cancelled checkpoint and then throw.

const r = await sess.start('What did we decide about the rollout?');
console.log(r.status === 'done' ? r.text : r.state.suspendedOn?.reason);
async resume( resumeToken: string, value: unknown, opts?: { signal?: AbortSignal }, ): Promise<RunResult>

Resume the current suspended run with external input.

value is injected as a user message (strings verbatim, anything else JSON-stringified) and the agent loop continues from the persisted history revision. Throws if no run has started, the run is not suspended, the token does not match (tokens are single-use), or the suspension is a tool approval — those must go through approveTool() / rejectTool().

const r = await sess.start('book the trip');
if (r.status === 'suspended') {
  const done = await sess.resume(r.state.suspendedOn!.token, {
    departure: '2026-08-01',
  });
}
approveTool( resumeToken: string, opts: { approval?: unknown; signal?: AbortSignal } = {}, ): Promise<RunResult>

Approve a pending approval-required tool call and continue the run.

The tool executes exactly once, with opts.approval forwarded to its execution context, then the agent loop continues from the resulting tool message. Throws if no run has started, the run is not suspended, the token does not match, or the suspension is not a tool approval (use resume() for those).

const r = await sess.start('delete acct_1');
if (r.status === 'suspended') {
  const done = await sess.approveTool(r.state.suspendedOn!.token, {
    approval: { approvedBy: 'ops@example.com' },
  });
}
rejectTool( resumeToken: string, reason?: string, opts: { signal?: AbortSignal } = {}, ): Promise<RunResult>

Reject a pending approval-required tool call and continue the run with an error tool result visible to the model.

The tool never executes. reason (default 'not approved') becomes the error tool result, so the model can explain or try another approach. Throws under the same conditions as approveTool().

suspend(opts?: { reason?: string; payload?: unknown }): never

Suspend execution from inside workflow or application code.

Throws SuspendSignal and never returns. Call it from code running within a driven step (for example a tool body) to pause the run: the session checkpoints a suspended state with a fresh single-use resume token, and reason/payload surface on RunState.suspendedOn.

async fork( input: string | { messages: ModelMessage[] }, opts?: { runId?: string; signal?: AbortSignal }, ): Promise<RunResult>

Fork the current history into a new thread and start a new run.

The fork shares the committed history up to the current revision and then diverges: it gets a freshly generated threadId and its own run state, and subsequent steps in either thread never affect the other. Because the history graph is immutable and shared, the fork costs only new revisions, not a copy of the conversation. Throws if no run has started or the current run has no committed history revision.

await sess.start('Draft the launch plan.');
const alt = await sess.fork('Now redo it assuming a two-week delay.');
console.log(alt.state.threadId !== sess.state!.threadId); // true
async cancel(): Promise<void>

Mark the current run as cancelled.

Commits a cancelled checkpoint for the current run. This does not interrupt an in-flight start()/resume() — pass an AbortSignal to those calls to stop work in progress. No-op when no run has started.

Static Methods

static async resume(opts: SessionOptions & { runId: string }): Promise<RunResult>

Resume a non-suspended run from its persisted checkpoint.

Intended for crash recovery: a run whose last checkpoint is running (the process died mid-drive) or error is re-driven from its committed history revision. Already-terminal runs (done, cancelled) return their stored result without invoking the agent.

Throws if the run id is unknown, or if the run is suspended — suspended runs need their resume token, via the instance resume() or Session.resumeSuspended().

import { Session } from 'fino:ai/session';

const result = await Session.resume({ store, agent: bot, runId });
console.log(result.status, result.text);
static async resumeSuspended( opts: SessionOptions & { runId: string; resumeToken: string; value: unknown; signal?: AbortSignal; }, ): Promise<RunResult>

Resume a suspended run from durable state.

Use this from stateless adapters such as HTTP channels where the original Session instance may no longer be in memory. The token is checked against the stored run and remains single-use. Throws if the run id is unknown, the run is not suspended, the token does not match, or the suspension is a tool approval — those go through approveSuspended() / rejectSuspended() instead.

import { Session } from 'fino:ai/session';

app.post('/runs/:runId/resume', async (req) => {
  const { token, value } = await req.json();
  return Session.resumeSuspended({
    store, agent: bot,
    runId: req.params.runId,
    resumeToken: token,
    value,
  });
});
static async approveSuspended( opts: SessionOptions & { runId: string; resumeToken: string; approval?: unknown; signal?: AbortSignal; }, ): Promise<RunResult>

Approve a persisted tool approval suspension after process restart.

Loads the run from the store and continues it as approveTool() would: the pending tool executes (exactly once), opts.approval is forwarded to its execution context, and the agent loop runs on to completion or the next suspension. Throws if the run id is unknown, the token does not match, or the run is not suspended for tool approval.

import { Session } from 'fino:ai/session';

const resumed = await Session.approveSuspended({
  store, agent: bot,
  runId: pending.runId,
  resumeToken: pending.state.suspendedOn!.token,
});
static async rejectSuspended( opts: SessionOptions & { runId: string; resumeToken: string; reason?: string; signal?: AbortSignal; }, ): Promise<RunResult>

Reject a persisted tool approval suspension after process restart.

The pending tool never executes; opts.reason (default 'not approved') is recorded as an error tool result and the model continues from there. Throws under the same conditions as approveSuspended(). Tokens are single-use across both decisions: once a suspension is approved it can no longer be rejected, and vice versa.

Functions

async function loadAgentRun(store: AtomicStore, runId: string): Promise<RunState | null>

Load a detached agent run checkpoint, or null.

async function listAgentRuns( store: AtomicStore, filter: { threadId?: string } = {}, ): Promise<RunState[]>

List detached agent run checkpoints, optionally restricted to one thread.

async function deleteAgentRun(store: AtomicStore, runId: string): Promise<void>

Delete a run checkpoint while retaining its thread and shared history.

async function loadConversationThread( store: AtomicStore, threadId: string, ): Promise<ThreadState | null>

Load a detached conversation thread with its current store version.

async function listConversationThreads(store: AtomicStore): Promise<ThreadState[]>

List detached conversation threads newest first.

async function deleteConversationThread( store: AtomicStore, threadId: string, ): Promise<boolean>

Delete one thread head while retaining runs and shared history nodes.

async function loadConversationHistory( store: AtomicStore, revisionId: string, ): Promise<MessageHistory | null>

Reconstruct an immutable conversation history from one graph revision.

async function commitConversationThread( store: AtomicStore, args: ConversationCommitOptions, ): Promise<ThreadState>

Atomically commit a conversation history delta, thread head, and metadata.

async function commitAgentSession( store: AtomicStore, args: AgentSessionCommitOptions, ): Promise<void>

Atomically commit a run checkpoint with its history and thread head.

function session(opts: SessionOptions): Session

Create a durable Session.

The factory for the Session class, whose constructor is private. Pass a threadId to continue an existing conversation thread; omit it to start a fresh one.

import { agent } from 'fino:ai/agent';
import { openai } from 'fino:ai/model';
import { sqliteStore } from 'fino:store';
import { session } from 'fino:ai/session';

const store = await sqliteStore({ path: './runs.db' });
const sess = session({
  store,
  agent: agent({ model: openai({ model: 'gpt-4o' }) }),
  threadId: 'customer-123',
});
const r = await sess.start('Where did we leave off?');