Nous.Session (nous v0.17.1)

Copy Markdown View Source

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:

  • id is the session id every other part of the system already keys on — Nous.AgentServer's session_id, the key a Nous.Persistence backend saves under, and the input to Nous.PubSub.agent_topic/1.
  • log is a Nous.Session.Log. It is the whole state; nothing here caches a projection of it.
  • parent is nil, 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. Because seq equals the index, seed_length is also the boundary: events with seq < seed_length came 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.

t()

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

boundary()

@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.

parent()

@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.

t()

@type t() :: %Nous.Session{
  id: String.t(),
  log: Nous.Session.Log.t(),
  parent: parent() | nil
}

Functions

fork(session, boundary \\ :last)

@spec fork(t(), term()) :: {:ok, t()} | {:error, term()}

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}

from_context(ctx, opts \\ [])

@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.

new(opts \\ [])

@spec new(keyword()) :: t()

Build a session.

Options

  • :id — the session id. Generated if omitted.
  • :log — a Nous.Session.Log (default: empty).
  • :parent — provenance, when the caller already knows it. fork/2 sets it.

Examples

iex> session = Nous.Session.new(id: "s1")
iex> {session.id, Nous.Session.Log.count(session.log), session.parent}
{"s1", 0, nil}

to_context(session)

@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.