Nous.AgentServer (nous v0.17.1)

Copy Markdown View Source

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
end

Message 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:

MessageDescription
{: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:

CallQueued forWakes an idle agent?
followup/2next turnyes
steer/2next stepyes
inject/2next stepno

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

agent_config()

@type agent_config() :: %{
  model: String.t(),
  instructions: String.t(),
  tools: list(),
  type: :standard | :react,
  model_settings: map()
}

state()

@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

cancel_execution(pid)

@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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear_history(pid)

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

followup(server, message)

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

get_context(pid)

@spec get_context(pid()) :: Nous.Agent.Context.t()

Get conversation context.

get_history(pid)

@spec get_history(pid()) :: list()

Get conversation history (messages only).

inject(server, message)

@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_context(pid, session_id)

@spec load_context(pid(), String.t()) :: :ok | {:error, term()}

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.

save_context(pid)

@spec save_context(pid()) :: :ok | {:error, term()}

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_message(pid, message)

@spec send_message(pid(), String.t()) :: :ok

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.

start_link(opts)

@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 :infinity to 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 - :standard or :react (default: :standard)
  • :model_settings - Model settings map
  • :deps - Initial dependencies for tools

steer(server, message)

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