A coordinated, multi-participant interaction with shared state.
ExAgent.Session is the coordination layer (Roadmap Phase 3): it owns a
piece of application-defined shared_state and a set of participants
(agents backed by ExAgent.Server, or humans driving via LiveView), and it
decides — through a pluggable ExAgent.Session.TurnPolicy — whose turn it is.
It is deliberately agnostic: the shared_state can be a D&D world, a support
ticket, a collaborative document, anything. ExAgent never assumes a shape.
The single-writer rule
The Session is the only process allowed to mutate shared_state.
Participants never touch it directly. Instead, a participant whose turn it is
passes a change function (state) -> {:ok, new_state} | {:error, reason},
and the Session applies it atomically, emits :shared_state_updated, and
advances the turn. Tools running inside an agent reach the Session through an
ExAgent.Session.SharedState handle placed in RunContext.deps.
Lifecycle
{:ok, session} =
ExAgent.Session.start_link(
shared_state: %{log: []},
policy: :round_robin,
participants: [
ExAgent.Session.Participant.new(id: "dm", kind: :agent),
ExAgent.Session.Participant.new(id: "player", kind: :human)
],
pubsub: :local
)
:ok = ExAgent.Session.start(session)
{:ok, state, next} = ExAgent.Session.take_turn(session, "dm", fn s -> {:ok, %{s | log: ["dm acted" | s.log]}} end)Status flows :created → :running → (:paused ⇄ :running) → :closed. While not
:running, take_turn/3 returns {:error, :paused} / {:error, {:not_running, _}}.
Events
Published on ExAgent.Event.session_topic(session_id) ("exagent:session:<id>"):
:session_started · :participant_joined · :participant_left · :session_turn_changed · :shared_state_updated · :session_paused · :session_resumed · :session_closed. Events carry ids/status, never the bulk
shared_state (read it with read_state/1).
Summary
Functions
Returns a specification to start this module under a supervisor.
Close the session. Further turns return {:error, {:not_running, :closed}}.
The participant id whose turn it currently is (or nil).
End the current participant's turn without changing state, advancing next.
Add a participant (allowed before or during a running session).
Remove a participant by id. Returns :ok or {:error, :not_found}.
All registered participants.
Pause a running session (take_turn/3 then returns {:error, :paused}).
Read the shared state (read-only snapshot; anyone may call this).
Resume a paused session.
Begin turns. Picks the first participant via the policy. :created → :running.
Start a supervised session.
Current session status (:created | :running | :paused | :closed | :done).
Take a turn: apply change_fn to the shared state atomically, then advance to
the next participant. change_fn is (state) -> {:ok, new_state} | {:error, reason} (a bare value is treated as the new state).
Mutate the shared state without advancing the turn — for mid-turn changes (e.g. a tool inside an agent run proposes a change). Only the current participant may call this.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec close(GenServer.server()) :: :ok
Close the session. Further turns return {:error, {:not_running, :closed}}.
@spec current(GenServer.server()) :: term() | nil
The participant id whose turn it currently is (or nil).
@spec end_turn(GenServer.server(), term()) :: {:ok, term()} | {:error, term()}
End the current participant's turn without changing state, advancing next.
@spec join(GenServer.server(), ExAgent.Session.Participant.t() | keyword()) :: :ok
Add a participant (allowed before or during a running session).
@spec leave(GenServer.server(), term()) :: :ok | {:error, :not_found}
Remove a participant by id. Returns :ok or {:error, :not_found}.
@spec participants(GenServer.server()) :: [ExAgent.Session.Participant.t()]
All registered participants.
@spec pause(GenServer.server()) :: :ok | {:error, term()}
Pause a running session (take_turn/3 then returns {:error, :paused}).
@spec read_state(GenServer.server()) :: term()
Read the shared state (read-only snapshot; anyone may call this).
@spec resume(GenServer.server()) :: :ok | {:error, term()}
Resume a paused session.
@spec start(GenServer.server()) :: {:ok, term()} | {:error, term()}
Begin turns. Picks the first participant via the policy. :created → :running.
@spec start_link(keyword()) :: GenServer.on_start()
Start a supervised session.
Options
:shared_state— the initial application-defined state (required-ish; defaults tonil).:policy—:round_robin/:initiative/{:initiative, order: [...]}/{module, opts}/module(default:round_robin).:participants— initial list ofExAgent.Session.Participant.t().:session_id— stable id for events; auto-generated if absent.:pubsub—nil/:none/:local/{module, config}(defaultnil).:store—nil/:ets/{module, config}(defaultnil, nopersistence). When set, `shared_state` + turn position are checkpointed after every change and rehydrated on restart. The live participant `ref`s come from `:participants` on restart; only the serializable coordination state is restored.:name— registered name for the GenServer.:metadata— free-form map attached to every emitted event.
@spec status(GenServer.server()) :: :created | :running | :paused | :closed | :done
Current session status (:created | :running | :paused | :closed | :done).
@spec take_turn(GenServer.server(), term(), (term() -> {:ok, term()} | {:error, term()} | term())) :: {:ok, term(), term()} | {:error, term()}
Take a turn: apply change_fn to the shared state atomically, then advance to
the next participant. change_fn is (state) -> {:ok, new_state} | {:error, reason} (a bare value is treated as the new state).
Returns {:ok, new_state, next_participant_id} or {:error, reason} —
:not_your_turn, :paused, or {:not_running, status}.
@spec update_state(GenServer.server(), term(), (term() -> {:ok, term()} | {:error, term()} | term())) :: {:ok, term()} | {:error, term()}
Mutate the shared state without advancing the turn — for mid-turn changes (e.g. a tool inside an agent run proposes a change). Only the current participant may call this.