Nous.Session.Log (nous v0.17.1)

Copy Markdown View Source

An append-only event log with a model-visible surface derived by folding.

The log is the source of truth. derive_messages/1 projects it into the [%Nous.Message{}] list every existing caller already reads, so the log can be internal while result.messages stays byte-identical (plan constraint D2).

Replace, don't delete

{:ok, log} = Log.append(log, :assistant_message, %{content: "…"})
{:ok, log} = Log.append(log, :system_message, %{
  content: "[summary of 1-40]",
  surface_op: {:replace, 1, 40}
})

After that append, derive_messages/1 shows the summary in place of events 1 through 40, while events/1 still returns all of them. Compaction stops destroying history — which is the whole point, and what makes fork, rewind and audit possible later.

Cost

Folding on every read would make the O(n) append this replaced look cheap. The fold is memoized against replace_generation — a counter bumped only by a replace — plus the event count, so an ordinary append extends the cached list instead of rebuilding it, and a replace invalidates it exactly once.

What is not in the log

The system prompt rewrite performed per request by Nous.AgentRunner.PromptAssembly is assembly-time state, not history: it is derived from agent config, plugins and skills at request time, it is replaced wholesale on every request, and logging each rewrite would append N copies of the same text to a durable log. System messages that are genuinely part of the transcript — the initial one, and the summary Nous.Plugins.Summarization appends — are :system_message events like any other. See Nous.Session.Event.

Summary

Types

t()

events is kept newest-first so an append is O(1); events/1 reverses.

Functions

Append an event, logging and returning the log unchanged on failure.

The number of events, shadowed ones included.

Fold the surface into {seq, message} pairs, in the order the model sees them.

The message list every caller sees: derive_indexed/1 without the seqs.

Every event ever appended, oldest first, including shadowed ones.

derive_messages/1, returning the log with the fold memoized.

An empty log.

Seed a log from a flat message list, one surface event per message, in order.

Events from seq onward, oldest first. Costs O(number returned), not O(log).

The surface events, in the order the model sees them: surface-typed events that no later replace has shadowed.

Types

t()

@type t() :: %Nous.Session.Log{
  cache: {non_neg_integer(), non_neg_integer(), [Nous.Message.t()]} | nil,
  events: [Nous.Session.Event.t()],
  next_seq: non_neg_integer(),
  replace_generation: non_neg_integer()
}

events is kept newest-first so an append is O(1); events/1 reverses.

cache holds {replace_generation, event_count, messages} — see the moduledoc on cost.

Functions

append(log, type, data, time \\ nil)

@spec append(t(), Nous.Session.Event.type(), map(), DateTime.t() | nil) ::
  {:ok, t()} | {:error, term()}

Append an event.

seq is assigned here and is always contiguous — it equals the event's index, which is what lets {:replace, start, stop} name a range without storing pointers.

Returns {:error, reason} on an invalid event; a bad event must never take down a live run.

time defaults to now. Pass it explicitly when the event records something that already happened, which is required in two places: appending an existing %Nous.Message{} must reproduce that message's own created_at (the fold stamps from the event time, so re-stamping would make add_message/2 lossy), and rebuilding a log from persisted events must keep their original times rather than the moment of the restore.

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: "hello"})
iex> Enum.map(Nous.Session.Log.events(log), & &1.seq)
[0, 1]

append!(log, type, data, time \\ nil)

@spec append!(t(), Nous.Session.Event.type(), map(), DateTime.t() | nil) :: t()

Append an event, logging and returning the log unchanged on failure.

For call sites inside the agent loop, where losing one bookkeeping event is strictly better than failing the user's run. The failure is never silent.

count(log)

@spec count(t()) :: non_neg_integer()

The number of events, shadowed ones included.

derive_indexed(log)

@spec derive_indexed(t()) :: [{non_neg_integer(), Nous.Message.t()}]

Fold the surface into {seq, message} pairs, in the order the model sees them.

This is the primitive; derive_messages/1 drops the seqs. Compaction needs the pairs: to replace "these messages" it has to name a seq range, and the mapping from message position to originating event is exactly what the projection rules decide. Re-deriving that mapping outside this module means re-implementing the drop rule below, which is how the two would silently diverge.

Projection rules:

  • :system_message, :user_message, :assistant_message → a message of that role, reproduced field for field
  • :tool_result → a :tool message carrying tool_call_id and name
  • everything else → nothing

Nothing else is filtered. An assistant event with neither content nor tool calls still projects: it is what happened, and dropping it made Nous.Agent.Context.last_message/1 and output extraction disagree with the log.

Memoized; see the moduledoc on cost.

derive_messages(log)

@spec derive_messages(t()) :: [Nous.Message.t()]

The message list every caller sees: derive_indexed/1 without the seqs.

events(log)

@spec events(t()) :: [Nous.Session.Event.t()]

Every event ever appended, oldest first, including shadowed ones.

materialize(log)

@spec materialize(t()) :: {[Nous.Message.t()], t()}

derive_messages/1, returning the log with the fold memoized.

Callers that hold the log (Nous.Agent.Context) should use this so a later read is free. The cache holds the indexed pairs, so a later derive_indexed/1 is free too.

new()

@spec new() :: t()

An empty log.

seed(messages)

@spec seed([Nous.Message.t()]) :: t()

Seed a log from a flat message list, one surface event per message, in order.

This is the v1→v2 persistence bridge and what Nous.run(agent, messages: [...]) uses. Messages with an unrecognised role are dropped with a warning rather than guessed at.

Each event takes its message's own created_at as its time, so seeding is lossless: seed/1 |> derive_messages/1 returns the input list, timestamps included. Re-stamping here would make Nous.Agent.Context.new(messages: …) quietly rewrite the history it was handed.

since(log, seq)

@spec since(t(), non_neg_integer()) :: [Nous.Session.Event.t()]

Events from seq onward, oldest first. Costs O(number returned), not O(log).

events/1 reverses the whole list, which is fine for a one-off read and wrong for anything on the append path — publishing newly committed events runs on every append, and so does a consumer catching up from a known seq. Because events are stored newest-first, taking the tail walks only what is new.

seq at or beyond the end returns [].

Examples

iex> log = Nous.Session.Log.new()
iex> {:ok, log} = Nous.Session.Log.append(log, :user_message, %{content: "a"})
iex> {:ok, log} = Nous.Session.Log.append(log, :user_message, %{content: "b"})
iex> Nous.Session.Log.since(log, 1) |> Enum.map(& &1.data.content)
["b"]
iex> Nous.Session.Log.since(log, 5)
[]

surface(log)

@spec surface(t()) :: [Nous.Session.Event.t()]

The surface events, in the order the model sees them: surface-typed events that no later replace has shadowed.

A replacing event takes the position of the range it replaces, not its own append position. A summary of events 0–40 belongs where those events were; put at the end it would read as "here is the conversation, and now a summary of the part that came before it", and it would leave a :tool message at the head with no assistant prelude, which every provider rejects.

Examples

iex> log = Nous.Session.Log.new()
iex> {:ok, log} = Nous.Session.Log.append(log, :user_message, %{content: "one"})
iex> {:ok, log} = Nous.Session.Log.append(log, :step_start, %{})
iex> {:ok, log} = Nous.Session.Log.append(log, :assistant_message, %{content: "two"})
iex> Enum.map(Nous.Session.Log.surface(log), & &1.type)
[:user_message, :assistant_message]