GenServer wrapper for Nous agents with PubSub integration.
This server:
- Wraps a Nous agent (standard or ReAct)
- Links to parent process (dies when parent dies)
- Subscribes to PubSub for incoming messages
- Publishes responses back via PubSub
- Maintains conversation context for multi-turn conversations
Context-Based State
Uses Nous.Agent.Context to maintain conversation state:
- Messages accumulate across turns
- Tool calls are tracked
- Usage is aggregated
- Callbacks forward to PubSub
Usage with LiveView
defmodule MyAppWeb.ChatLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
# Start agent linked to this LiveView
{:ok, agent_pid} = AgentServer.start_link(
session_id: socket.assigns.session_id,
agent_config: %{
model: "lmstudio:qwen3-vl-4b-thinking-mlx",
instructions: "You are a helpful assistant",
tools: []
}
)
# Subscribe to responses. Always build the topic with
# Nous.PubSub.agent_topic/1 — it is what this server publishes to.
Phoenix.PubSub.subscribe(MyApp.PubSub, Nous.PubSub.agent_topic(socket.assigns.session_id))
{:ok, assign(socket, agent_pid: agent_pid, messages: [])}
end
def handle_event("send_message", %{"message" => msg}, socket) do
AgentServer.send_message(socket.assigns.agent_pid, msg)
{:noreply, socket}
end
# Receive streaming deltas
def handle_info({:agent_delta, text}, socket) do
# Append text to current response
{:noreply, update(socket, :current_response, &(&1 <> text))}
end
# Receive complete response
def handle_info({:agent_complete, result}, socket) do
messages = socket.assigns.messages ++ [%{role: :assistant, content: result.output}]
{:noreply, assign(socket, messages: messages, current_response: "")}
end
# Receive tool calls
def handle_info({:tool_call, call}, socket) do
# Show tool call in UI
{:noreply, socket}
end
endMessage Interruption
Calling send_message/2 while the agent is already processing a request
automatically cancels the in-flight execution and starts a new one. The
server uses an :atomics-based flag so the running task can detect
cancellation without message-passing overhead. Both the interrupted
user message and the new one are preserved in the conversation context,
so no input is lost.
PubSub Events
Subscribers on the Nous.PubSub.agent_topic(session_id) topic (currently
"nous:agent:<session_id>") receive the following messages:
| Message | Description |
|---|---|
{:agent_status, :thinking} | A new run is about to start |
{:agent_status, :started} | The LLM provider acknowledged the call |
{:agent_delta, text} | A streaming text chunk |
{:tool_call, call} | A tool invocation is in progress |
{:tool_result, result} | A tool returned its result |
{:agent_response, output} | The final text output of the run |
{:agent_complete, result} | The full result struct (output + context + usage) |
{:agent_error, message} | An error occurred during execution |
{:agent_cancelled, reason} | The execution was cancelled |
{:session_event, %Nous.Session.Event{}} | One committed log event — surface and bookkeeping alike |
{:session_event, _} is published by Nous.Agent.Context itself for every
event committed to the session log, which is why nothing in this server
broadcasts it: init/1 already points the run context at this topic. It is
the message to render a transcript from, since it also carries the turn and
step boundaries (:turn_start, :step_start, :step_end, :turn_end) that
the ad-hoc callbacks above cannot express.
Steering and injection
send_message/2 is the interrupting path: it cancels whatever is in flight
and starts over. The inbox paths never interrupt anything — they queue a
message and let the run claim it at its next boundary:
| Call | Queued for | Wakes an idle agent? |
|---|---|---|
followup/2 | next turn | yes |
steer/2 | next step | yes |
inject/2 | next step | no |
steer/2 and inject/2 differ in exactly one bit, and it is the bit people
get wrong. Worked example, on an agent that is idle after its last run:
# Nothing happens. No model request, no tokens, no run. The note is queued
# and will be handed to the next request the agent makes anyway.
AgentServer.inject(pid, "FYI: the staging deploy is frozen until 14:00")
# This starts a run. It claims the injected note *and* this message, in
# that order, so both are in the first request's message list.
AgentServer.steer(pid, "Which services are still pending release?")Mid-run, the distinction is about latency rather than about starting work:
AgentServer.send_message(pid, "Audit every service for stale configs")
# ... the agent is three tool calls deep ...
AgentServer.steer(pid, "Actually, skip anything under /legacy")
# The NEXT model request of that same run sees the instruction. The tool
# call already in flight is neither interrupted nor cancelled, and the
# message is not claimed by the request that is already on the wire.Both accept a binary or a Nous.Message, and both are safe to call whether or
not a run is in flight.
Run state
The server keeps an explicit run_state of :idle or :running, and every
transition into :idle goes through one function. That is what makes wakeup
decidable: steer/2 on an idle agent must start work and inject/2 must not,
and both answers depend on knowing — not inferring — whether a run is in
flight.
The upstream implementation this was ported from needs a wakeRequested latch
to cover the window where a message lands after the last claim of a run that
is about to end. There is no latch here. The wake intent rides on the queued
message itself (see Nous.Session.Inbox), so the :running -> :idle
transition just asks the inbox whether a run is still owed. Enqueueing and
finishing are both serialized through this process's mailbox, so exactly one
of the two starts the run.
Lifecycle
Each server starts an inactivity timer (default 5 minutes, configurable
via :inactivity_timeout). The timer resets on every send_message/2
call. When the timer fires, the server terminates with :normal.
If a :persistence backend is configured, the conversation context is
automatically saved after each successful agent run and restored on
start_link/1.
Summary
Functions
Cancel the current agent execution.
Returns a specification to start this module under a supervisor.
Clear conversation context and start fresh.
Queue a message for the agent's next turn, waking it if it is idle.
Get conversation context.
Get conversation history (messages only).
Queue context for the agent's next step without waking it.
Load a previously saved context from the persistence backend.
Manually save the current context to the persistence backend.
Send a message to the agent.
Start an AgentServer linked to the calling process.
Queue a message for the agent's next step, waking it if it is idle.
Types
@type state() :: %{ session_id: String.t(), agent: Nous.Agent.t(), context: Nous.Agent.Context.t(), pubsub: module() | nil, topic: String.t(), agent_type: :standard | :react, current_task: Task.t() | nil, task_generation: non_neg_integer(), cancelled_ref: :atomics.atomics_ref(), inactivity_timeout: timeout(), inactivity_timer_ref: reference() | nil, persistence: module() | nil, run_state: :idle | :running, inbox: Nous.Session.Inbox.t() }
Functions
@spec cancel_execution(pid()) :: {:ok, :cancelled} | {:ok, :no_execution}
Cancel the current agent execution.
Returns {:ok, :cancelled} when an execution was running and has been
stopped, or {:ok, :no_execution} when there was nothing to cancel.
The server will:
- Set the atomics cancellation flag so the task exits at the next check
- Shut down the running task gracefully (5 s timeout)
- Broadcast
{:agent_cancelled, reason}to PubSub subscribers - Reset the flag for future executions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear_history(pid()) :: :ok
Clear conversation context and start fresh.
Resets messages and tool-call history but preserves the configured
dependencies (:deps) and system prompt.
@spec followup(GenServer.server(), Nous.Message.t() | String.t()) :: :ok
Queue a message for the agent's next turn, waking it if it is idle.
"Answer this once you have finished what you are doing." Unlike
send_message/2 it never cancels the run in flight, and unlike steer/2 it
does not join that run: it is claimed when the next turn opens.
message may be a binary or a Nous.Message. Returns immediately.
@spec get_context(pid()) :: Nous.Agent.Context.t()
Get conversation context.
Get conversation history (messages only).
@spec inject(GenServer.server(), Nous.Message.t() | String.t()) :: :ok
Queue context for the agent's next step without waking it.
Injected context waits for the next admitted request rather than starting one:
on an idle agent this is inert until something else makes the agent run, and
on a running agent it behaves exactly like steer/2.
Use it for material the agent should have if it asks another question — retrieved documents, a changed permission, a note from another process — where spending a model request purely to deliver it would be wrong.
message may be a binary or a Nous.Message. Returns immediately.
Load a previously saved context from the persistence backend.
Replaces the current context with the loaded one. Patches any dangling tool calls that may have been interrupted mid-execution.
Returns :ok on success, {:error, :no_persistence} if no backend is configured,
or {:error, reason} on failure.
Manually save the current context to the persistence backend.
Returns :ok on success, {:error, :no_persistence} if no backend is configured,
or {:error, reason} on failure.
Send a message to the agent.
If a previous execution is still running, it is automatically cancelled before the new message is processed. The interrupted message and the new one both remain in the conversation context. Returns immediately — the agent run happens asynchronously and results are broadcast via PubSub.
@spec start_link(keyword()) :: GenServer.on_start()
Start an AgentServer linked to the calling process.
Options
:session_id- Unique session identifier (required):agent_config- Agent configuration map (required):pubsub- PubSub module (default: MyApp.PubSub):name- Optional GenServer name (e.g., a Registry via tuple):inactivity_timeout- Inactivity timeout in ms (default: 5 minutes). Set to:infinityto disable.:persistence- Persistence backend module (e.g.,Nous.Persistence.ETS). When set, context is auto-saved after each response and restored on init.
Agent Config
:model- Model string (e.g., "openai:gpt-4"):instructions- System instructions:tools- List of tool functions:type-:standardor:react(default: :standard):model_settings- Model settings map:deps- Initial dependencies for tools
@spec steer(GenServer.server(), Nous.Message.t() | String.t()) :: :ok
Queue a message for the agent's next step, waking it if it is idle.
Mid-run steering. The message is claimed by the next model request of the run already in flight — not by the request currently on the wire — and nothing is cancelled or discarded. On an idle agent it starts a run.
message may be a binary or a Nous.Message. Returns immediately.
Contrast inject/2, which queues to the same place and never starts a run.
The moduledoc has a worked example of the difference.