Nous.Session.Invariant (nous v0.17.1)

Copy Markdown View Source

Every model-visible request must be reconstructable from the session log.

Nous.Session.Log is the source of truth and ctx.messages is a view over it (see Nous.Agent.Context). That only buys fork, rewind, replay, non-destructive compaction and audit if the list actually handed to a provider is the list the fold produces — otherwise the log records a conversation the model never had, and every feature built on replaying it is quietly wrong. This module is that assertion, checked on the agent loop's request path.

The rule, exactly

check/2 walks the request and Nous.Session.Log.derive_indexed/1 in lockstep and requires, position for position, the same role, content, tool_calls, tool_call_id and name. There are exactly two exemptions, and both are assembly-time system-prompt state that is deliberately not in the log:

  1. The overlay slot. Nous.Agent.Context.put_system_prompt_overlay/2 holds the fragment Nous.AgentRunner.PromptAssembly re-derives from agent config, plugins and skills on every run. Context's apply_overlay/2 either concatenates it onto the transcript's leading system message or, when the fold has no leading system message, prepends a system message that no event produced. So a leading :system message in the request facing a non-:system head in the fold is skipped, and that is the only length difference tolerated. Nous.Agents.BasicAgent.build_messages/2 prepends ctx.system_prompt the same way when the transcript carries no system message at all; the same exemption covers it.
  2. System content. Where both sides hold a :system message, its content is not compared — only the role and the position. The overlay is concatenated into that content, and a behaviour may substitute its own assembled prompt wholesale (Nous.Agents.ReActAgent and Nous.Agents.KnowledgeBaseAgent reject the transcript's system messages and prepend a freshly built one). System text is re-derived per request, so the log cannot say what it should be; asserting on it would be asserting on agent config.

Everything else is exact. A user, assistant or tool message no event produced, an event-derived message the request dropped, and the same messages in a different order are all violations — including a mid-transcript system message the request omitted, which is a genuine loss of model-visible history (a Nous.Plugins.Summarization summary is a system message, and it stands in for every event it shadowed).

metadata and created_at are not compared. They are not part of the request ordering the log has to reproduce, and the request's messages are the fold's own terms in the common case, so the fields that matter are compared by pointer.

The orphaned tool result

Checked on the request alone: a :tool message whose tool_call_id appears in no preceding assistant message's tool_calls. This is the provider-400 class the whole boundary-balancing machinery exists to prevent, and it survives a perfectly reconstructable request — a positional {:replace, start, stop} over an unbalanced range shadows an assistant message and leaves its result standing, so the fold itself is unbalanced. Any caller that picks its own range can do this; Nous.Plugins.Summarization avoids it only because it runs Nous.Transcript.balance_tool_call_boundary/2 first.

Reconstruction is reported before pairing: if the request does not match the fold, the pairing diagnosis is about a list we already know is wrong.

What this actually catches

A request builder that invents or drops messages at assembly time — a behaviour's build_messages/2, or anything else between the transcript and the provider — plus the orphaned tool result above.

Notably not the legacy %{ctx | messages: ...} write D2 keeps alive: Nous.Agent.Context's sync/1 reseeds the log from whatever the caller assembled on the very next append, and the loop appends a :step_start before every request, so by dispatch the fold agrees with the request again. That write rewrites history rather than diverging from it, which is a different (documented, transitional) sin and not one a request-time check can see.

Modes

config :nous, :session_invariant, :warn | :strict | :off — default :warn.

  • :warn — emit [:nous, :session, :invariant, :violation] with the details as metadata and Logger.warning/1. Never raises, and that is the point: a user-supplied behaviour or plugin is entitled to assemble a request the transcript does not literally contain (dropping a mid-transcript system message is one of the shipped behaviours doing exactly that), and D2 keeps the legacy %{ctx | messages: ...} writers alive for at least one release. A checker that raised would take down a live, paid-for run over a bookkeeping discrepancy — wrong by construction.
  • :strict — telemetry, then raise Nous.Session.Invariant.Violation. This is for our own test suite, which is allowed to treat a divergence as a bug because it owns every writer. Do not turn it on in production.
  • :off — skip the check entirely, before any work. For hot paths that have measured the cost and decided against it.

Cost

This runs on every request, so check/2 is one O(n) pass over the message list plus one O(n) pass for tool pairing, with no intermediate lists: the fold comes from derive_indexed/1, which is memoized in the log, and the per-message comparison hits == on terms the request and the fold physically share. Only a violation allocates, and what it reports is bounded (see details/0).

What is not checked

Nous.Plugins.Summarization asks the model to write a summary through Nous.ModelDispatcher directly. That request is a slice of the transcript plus an instruction, not the session's own request, and it is correctly invisible here. Nous.AgentRunner.RequestDispatch documents the one other request path that has no context in scope.

Summary

Types

Telemetry metadata and Violation payload.

What went wrong.

:warn logs and emits telemetry, :strict raises, :off skips the check.

Functions

Compare a request against the fold of ctx.log. Pure.

The configured mode: config :nous, :session_invariant, :warn when unset.

The three valid modes.

Check the request, then act on the configured mode.

The telemetry event a violation emits: [:nous, :session, :invariant, :violation].

Types

details()

@type details() :: %{:kind => kind(), optional(atom()) => term()}

Telemetry metadata and Violation payload.

Reconstruction violations carry :index (the first diverging position), :request_length, :log_length and :extra/:missing. The lengths and the index are all measured after the overlay slot is skipped, so they line up with each other. :extra/:missing are bounded lists of message sketches, not messages, so a violation cannot dump a multimodal transcript into a log line.

:orphaned_tool_result carries the offending :tool_call_id.

kind()

@type kind() :: :extra_message | :missing_message | :order | :orphaned_tool_result

What went wrong.

  • :extra_message — the request carries a message no event produced
  • :missing_message — the fold produced a message the request dropped
  • :order — the same messages, in a different order
  • :orphaned_tool_result — a :tool message with no preceding tool call

mode()

@type mode() :: :off | :warn | :strict

:warn logs and emits telemetry, :strict raises, :off skips the check.

Functions

check(ctx, request_messages)

@spec check(Nous.Agent.Context.t(), [Nous.Message.t()]) ::
  :ok | {:violation, details()}

Compare a request against the fold of ctx.log. Pure.

Reads no configuration, logs nothing, emits nothing: verify/2 is the side effect. See the moduledoc for the exact rule and for what the two lists are allowed to differ by.

Examples

iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
iex> Nous.Session.Invariant.check(ctx, ctx.messages)
:ok

iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
iex> {:violation, details} = Nous.Session.Invariant.check(ctx, [])
iex> details.kind
:missing_message

mode()

@spec mode() :: mode()

The configured mode: config :nous, :session_invariant, :warn when unset.

A value that is neither a mode nor its string form falls back to the default and warns once per VM, rather than raising: a typo in one config line must not take down every run.

Examples

iex> Nous.Session.Invariant.mode() in Nous.Session.Invariant.modes()
true

modes()

@spec modes() :: [mode()]

The three valid modes.

verify(ctx, request_messages)

@spec verify(Nous.Agent.Context.t() | nil, [Nous.Message.t()]) :: :ok

Check the request, then act on the configured mode.

Returns :ok in every mode except :strict, where a violation raises Nous.Session.Invariant.Violation. :off short-circuits before any work, and a nil context (a request path with no session in scope) is a no-op.

Examples

iex> ctx = Nous.Agent.Context.new() |> Nous.Agent.Context.add_message(Nous.Message.user("hi"))
iex> Nous.Session.Invariant.verify(ctx, ctx.messages)
:ok

violation_event()

@spec violation_event() :: [atom()]

The telemetry event a violation emits: [:nous, :session, :invariant, :violation].

Measurements are %{count: 1}; metadata is details/0 plus :agent_name and :mode.