A session: an id, its event log, and — if it was forked — where it came from.
What a session is at this stage, and what it is not
Deliberately the smallest thing that carries {id, log, parent} honestly. It
is a handle on a log with provenance, not a new persistence layer:
idis the session id every other part of the system already keys on —Nous.AgentServer'ssession_id, the key aNous.Persistencebackend saves under, and the input toNous.PubSub.agent_topic/1.logis aNous.Session.Log. It is the whole state; nothing here caches a projection of it.parentisnil, or%{session_id: id, seed_length: n}— the session this one was forked from, and how many of this log's events were inherited from it. Becauseseqequals the index,seed_lengthis also the boundary: events withseq < seed_lengthcame from the parent, everything at or after it belongs to this session.
There is no new serialization format and no new backend. Persistence already
has exactly one contract — the four-callback Nous.Persistence behaviour, with
ETS as the only shipped implementation — and Nous.Agent.Context.serialize/1
is already version: 2 carrying the full event list. A session persists by
going through those:
{:ok, ctx} = Nous.Session.to_context(session)
MyBackend.save(session.id, Nous.Agent.Context.serialize(ctx))Known limit, stated rather than papered over: that round trip carries the
log, not the parent link. Recording provenance durably means adding a field
to the v2 blob, which is a change to Context.serialize/1 and belongs in the
phase that owns it. Until then a fork's parentage is in-memory only, and a
caller that needs it durable must store it alongside (it is two scalars).
Forking
fork/2 copies a prefix of the log into a fresh session. It refuses a
boundary that lands inside an open turn — see fork/2 for why that is an
error and not a clip.
Rendering from the log
Every event committed into a Nous.Agent.Context is broadcast as
{:session_event, %Nous.Session.Event{}}on the context's pubsub_topic — which Nous.AgentServer sets to
Nous.PubSub.agent_topic(session_id), so this rides the existing topic scheme
rather than adding one. A LiveView subscribing there can render the
transcript, tool calls, turns and steps from the log itself instead of from
ad-hoc callbacks, and Nous.Session.Log.since/2 fills the gap between the
last seq it rendered and the log it loads on reconnect.
Bookkeeping events are published too — :turn_start, :step_end and friends
are exactly what a UI needs to show progress, and the existing
{:agent_delta, _}/{:tool_call, _} callback messages stay untouched
alongside them. Publishing is a no-op when the context has no pubsub or no
topic, which is every context built without them.
Summary
Types
Where to cut a fork: the seq of the last event to inherit (inclusive, the
same convention as {:replace, start, stop}), or :last for the whole log.
Where a forked session came from: the parent's id, and how many of this session's leading events were inherited from it.
Functions
Fork a session at boundary, copying the event prefix into a new session.
A session over the log a context is holding.
Build a session.
Rebuild a runnable Nous.Agent.Context from a session's log.
Types
@type boundary() :: non_neg_integer() | :last
Where to cut a fork: the seq of the last event to inherit (inclusive, the
same convention as {:replace, start, stop}), or :last for the whole log.
@type parent() :: %{session_id: String.t(), seed_length: non_neg_integer()}
Where a forked session came from: the parent's id, and how many of this session's leading events were inherited from it.
@type t() :: %Nous.Session{ id: String.t(), log: Nous.Session.Log.t(), parent: parent() | nil }
Functions
Fork a session at boundary, copying the event prefix into a new session.
boundary is a boundary/0: the seq of the last event to inherit, or
:last for the whole log. Anything else comes back as
{:error, {:invalid_boundary, term}} — the argument is validated rather than
merely typed, because a boundary usually arrives from outside the code (a UI
selection, a stored bookmark, a seq read back off a persisted blob).
The fork records its parent's id and the number of events it inherited, so it stays traceable to its origin.
Copied events keep their original seq. That is not cosmetic: a
{:replace, start, stop} names a range by position, and renumbering the
prefix would silently move every compaction range in it. It also means
seed_length cleanly separates inherited history from the fork's own.
Why an open turn is an error
{:error, {:open_turn, seq}}A prefix that ends inside a turn that never closed would hand the fork a half-finished turn as its inheritance: tool calls owing results that will never arrive, and a turn a later reader cannot tell from one this fork abandoned itself. Clipping back to the last clean boundary is the other obvious option and it is worse — it silently returns something other than what the caller asked for, and the caller cannot tell it happened.
:last is not special-cased. If the log's final turn is open, :last is a
boundary inside an open turn and errors like any other. The caller has two
honest fixes: Nous.Session.Recovery.recover/1 first, which closes the turn
with reason: :interrupted and makes :last legal, or pick an earlier
boundary explicitly.
Examples
iex> log = Nous.Session.Log.new()
iex> {:ok, log} = Nous.Session.Log.append(log, :user_message, %{content: "hi"})
iex> {:ok, log} = Nous.Session.Log.append(log, :assistant_message, %{content: "yo"})
iex> {:ok, fork} = Nous.Session.fork(Nous.Session.new(id: "parent", log: log), 0)
iex> {fork.parent, Nous.Session.Log.count(fork.log)}
{%{session_id: "parent", seed_length: 1}, 1}
@spec from_context( Nous.Agent.Context.t(), keyword() ) :: t()
A session over the log a context is holding.
The context keeps its own copy; this takes a handle on the same log, which is immutable, so the two cannot drift.
Build a session.
Options
:id— the session id. Generated if omitted.:log— aNous.Session.Log(default: empty).:parent— provenance, when the caller already knows it.fork/2sets it.
Examples
iex> session = Nous.Session.new(id: "s1")
iex> {session.id, Nous.Session.Log.count(session.log), session.parent}
{"s1", 0, nil}
@spec to_context(t()) :: {:ok, Nous.Agent.Context.t()} | {:error, term()}
Rebuild a runnable Nous.Agent.Context from a session's log.
Goes through the shipped version: 2 reader rather than assigning
ctx.log directly: messages is a materialized view of the log, and a
caller that sets one without the other leaves them out of step. Using the
persistence path means a forked session loads by exactly the same code as a
restored one, bookkeeping events included.
Runtime-only fields (pubsub, notify_pid, callbacks, deps) come back empty,
as they do for any restored context — Nous.AgentServer re-attaches its own.