Nous.Session.Inbox (nous v0.17.1)

Copy Markdown View Source

Two ordered queues of messages waiting to enter a run: next_turn and next_step.

This is what makes an agent something you can talk to while it is working. A run claims from the inbox at two boundaries — once when a turn opens, and again at every step boundary — so a message that arrives mid-run joins the next model request instead of being lost, queued behind a minutes-long tool call, or cancelling the work already in flight.

One primitive, three presets

send/4 is the primitive: a message, a target queue, and whether the send should wakeup an idle agent. The three presets are the only combinations anyone has needed:

PresetTargetWakes an idle agent?Meaning
followup/2:next_turnyes"answer this after you finish what you are doing"
steer/2:next_stepyes"read this before your next model request"
inject/2:next_stepno"have this to hand if you make another request"

The fourth combination — :next_turn without a wake — is deliberately not a preset: a turn only opens when something starts it, so a non-waking turn message would sit until an unrelated run happened to begin. send/4 still allows it for anyone who wants exactly that.

inject/2 is the subtle one, and it is the whole reason the primitive takes wakeup separately from target: injected context waits for the next admitted request rather than starting one. Injecting into an idle agent costs nothing and does nothing observable until the agent runs again for some other reason. Steering an idle agent starts it.

Ordering

Each queue is FIFO: messages are claimed in the order they were sent.

The two queues are never merged and never compete. They are drained at different moments in a run, and that ordering — not a priority rule — is what decides who goes first when both hold messages:

turn opens  claim(:next_turn)  step boundary  claim(:next_step)  model request

So with both queues populated, everything in next_turn lands in the transcript ahead of everything in next_step, because the turn's claim happens before the first step's. Within each queue, send order is preserved. On later steps of the same turn only next_step is claimed — next_turn is already empty, and anything arriving there afterwards waits for a genuinely new turn.

Pure data

No processes, no timers, no pids. The inbox is a value its owner keeps (for agents driven by Nous.AgentServer, that owner is the server's state), and claiming returns the drained inbox rather than mutating anything.

It is also serializable: send/4 normalizes a binary to a Nous.Message, and nothing else ever enters the queues. A pid in here would fail Nous.Session.Event.new/4's serializability validation the moment it reached the log, and would be meaningless after a restart anyway.

Not part of streaming

Nous.AgentRunner.run_stream/3 runs exactly one iteration and is out of scope for turns, steps and the inbox by design. It never claims, so nothing queued here can reach it.

Example

iex> alias Nous.Session.Inbox
iex> inbox = Inbox.new() |> Inbox.inject("recall: the deploy is frozen") |> Inbox.steer("stop and summarize")
iex> Inbox.wake?(inbox)
true
iex> {claimed, inbox} = Inbox.claim(inbox, :next_step)
iex> Enum.map(claimed, & &1.content)
["recall: the deploy is frozen", "stop and summarize"]
iex> Inbox.pending?(inbox)
false

Summary

Types

One queued message plus the wake intent of the send/4 that queued it.

t()

Which boundary a message waits for.

Functions

Take everything queued on target, in send order, and return the drained inbox.

How many messages are queued across both queues.

Queue message for the next turn, waking an idle agent.

Queue message for the next step without waking anything.

An empty inbox.

Whether anything at all is queued.

Whether anything is queued on target.

Queue message on target, recording whether it should wake an idle agent.

Queue message for the next step, waking an idle agent.

Whether any queued message asked for a wake.

Types

entry()

@type entry() :: %{message: Nous.Message.t(), wakeup: boolean()}

One queued message plus the wake intent of the send/4 that queued it.

wakeup belongs to the entry rather than to the inbox as a whole because it has to survive being queued: a steer/2 that arrives while the last step of a run is already in flight is still pending when the run ends, and it is that entry — not a flag somebody set on the side — that says a new run is owed.

t()

@type t() :: %Nous.Session.Inbox{next_step: [entry()], next_turn: [entry()]}

target()

@type target() :: :next_turn | :next_step

Which boundary a message waits for.

Functions

claim(inbox, target)

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

Take everything queued on target, in send order, and return the drained inbox.

The other queue is untouched.

Examples

iex> alias Nous.Session.Inbox
iex> inbox = Inbox.new() |> Inbox.followup("later") |> Inbox.steer("now")
iex> {claimed, inbox} = Inbox.claim(inbox, :next_turn)
iex> Enum.map(claimed, & &1.content)
["later"]
iex> Inbox.pending?(inbox, :next_step)
true

count(inbox)

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

How many messages are queued across both queues.

followup(inbox, message)

@spec followup(t(), Nous.Message.t() | String.t()) :: t()

Queue message for the next turn, waking an idle agent.

"Answer this once you have finished what you are doing." It does not interrupt a run in flight, and it does not join the current turn.

inject(inbox, message)

@spec inject(t(), Nous.Message.t() | String.t()) :: t()

Queue message for the next step without waking anything.

Context for a request the agent was going to make anyway. On an idle agent this is inert: it waits for the next admitted request rather than starting one.

new()

@spec new() :: t()

An empty inbox.

pending?(inbox)

@spec pending?(t()) :: boolean()

Whether anything at all is queued.

pending?(inbox, target)

@spec pending?(t(), target()) :: boolean()

Whether anything is queued on target.

send(inbox, message, target, wakeup)

@spec send(t(), Nous.Message.t() | String.t(), target(), boolean()) :: t()

Queue message on target, recording whether it should wake an idle agent.

The primitive behind followup/2, steer/2 and inject/2. message may be a Nous.Message or a binary, which becomes a user message.

Examples

iex> inbox = Nous.Session.Inbox.send(Nous.Session.Inbox.new(), "hi", :next_step, false)
iex> Nous.Session.Inbox.wake?(inbox)
false

steer(inbox, message)

@spec steer(t(), Nous.Message.t() | String.t()) :: t()

Queue message for the next step, waking an idle agent.

Mid-run steering: the next model request in the current run sees it. On an idle agent it starts a run.

wake?(inbox)

@spec wake?(t()) :: boolean()

Whether any queued message asked for a wake.

This is the question an idle agent's owner asks — both when a message arrives and again when a run finishes. Because the answer is carried by the entries themselves, a steer/2 that lands too late for the run in flight is still answered by the next transition instead of needing a flag to remember it.

Examples

iex> alias Nous.Session.Inbox
iex> Inbox.new() |> Inbox.inject("fyi") |> Inbox.wake?()
false
iex> Inbox.new() |> Inbox.inject("fyi") |> Inbox.steer("go") |> Inbox.wake?()
true