Packages
nous
0.12.13
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/teams/coordinator.ex
defmodule Nous.Teams.Coordinator do
@moduledoc """
Team lifecycle GenServer that manages agent processes within a team.
The Coordinator is responsible for spawning, stopping, and monitoring agent
processes. It registers agents in the `Nous.AgentRegistry` with team-scoped
keys and sets up PubSub subscriptions.
## Architecture
Each team has exactly one Coordinator, started as part of `Nous.Teams.Supervisor`.
The Coordinator uses the team's DynamicSupervisor to start `AgentServer` processes.
Teams.Supervisor
├── Coordinator (this module)
├── SharedState
├── RateLimiter (optional)
└── DynamicSupervisor (for agents)
├── AgentServer ("alice")
└── AgentServer ("bob")
## Quick Start
{:ok, pid} = Coordinator.start_link(
team_id: "team_1",
team_name: "Research Team",
pubsub: MyApp.PubSub,
agent_supervisor: agent_sup_pid,
shared_state: shared_state_pid
)
{:ok, agent_pid} = Coordinator.spawn_agent(pid, "alice", %{
model: "openai:gpt-4",
instructions: "You are a researcher"
})
agents = Coordinator.list_agents(pid)
Coordinator.stop_agent(pid, "alice")
Coordinator.dissolve(pid)
"""
use GenServer
require Logger
alias Nous.Teams.Comms
@type agent_info :: %{
name: String.t(),
pid: pid(),
status: :running | :stopped
}
# Client API
@doc """
Start a Coordinator for a team.
## Options
- `:team_id` (required) — unique identifier for the team
- `:team_name` — human-readable team name (default: team_id)
- `:pubsub` — PubSub module for messaging
- `:agent_supervisor` — pid of the team's DynamicSupervisor for agents
- `:shared_state` — pid of the team's SharedState process
- `:rate_limiter` — pid of the team's RateLimiter process (optional)
- `:name` — optional GenServer name
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
{gen_opts, init_opts} = split_gen_opts(opts)
GenServer.start_link(__MODULE__, init_opts, gen_opts)
end
@doc """
Spawn a new agent within the team.
The agent is started under the team's DynamicSupervisor, registered in
`Nous.AgentRegistry` with a `{:team, team_id, name}` key, and subscribed
to team PubSub topics.
## Options
- `:role` — a `Nous.Teams.Role` struct to apply
- `:plugins` — list of plugin modules for the agent
## Examples
{:ok, pid} = Coordinator.spawn_agent(coordinator, "alice", %{
model: "openai:gpt-4",
instructions: "Research specialist"
})
"""
@spec spawn_agent(pid(), String.t(), map(), keyword()) :: {:ok, pid()} | {:error, term()}
def spawn_agent(pid, agent_name, agent_config, opts \\ []) do
GenServer.call(pid, {:spawn_agent, agent_name, agent_config, opts})
end
@doc """
Stop an agent by name.
## Examples
:ok = Coordinator.stop_agent(coordinator, "alice")
"""
@spec stop_agent(pid(), String.t()) :: :ok | {:error, :not_found}
def stop_agent(pid, agent_name) do
GenServer.call(pid, {:stop_agent, agent_name})
end
@doc """
List all agents in the team.
Returns a list of maps with `:name`, `:pid`, and `:status` keys.
## Examples
agents = Coordinator.list_agents(coordinator)
# [%{name: "alice", pid: #PID<0.123.0>, status: :running}]
"""
@spec list_agents(pid()) :: [agent_info()]
def list_agents(pid) do
GenServer.call(pid, :list_agents)
end
@doc """
Get the team's current status.
Returns a map with `:team_id`, `:team_name`, `:agent_count`, and `:agents` keys.
## Examples
status = Coordinator.team_status(coordinator)
# %{team_id: "team_1", team_name: "Research", agent_count: 2, agents: [...]}
"""
@spec team_status(pid()) :: map()
def team_status(pid) do
GenServer.call(pid, :team_status)
end
@doc """
Dissolve the team by stopping all agents and cleaning up.
## Examples
:ok = Coordinator.dissolve(coordinator)
"""
@spec dissolve(pid()) :: :ok
def dissolve(pid) do
GenServer.call(pid, :dissolve)
end
# Server
@impl true
def init(opts) do
team_id = Keyword.fetch!(opts, :team_id)
team_name = Keyword.get(opts, :team_name, team_id)
pubsub = Keyword.get(opts, :pubsub)
agent_supervisor = Keyword.get(opts, :agent_supervisor)
shared_state = Keyword.get(opts, :shared_state)
rate_limiter = Keyword.get(opts, :rate_limiter)
Logger.info("Team Coordinator started for team: #{team_name} (#{team_id})")
state = %{
team_id: team_id,
team_name: team_name,
pubsub: pubsub,
agent_supervisor: agent_supervisor,
shared_state: shared_state,
rate_limiter: rate_limiter,
agents: %{},
monitors: %{}
}
{:ok, state}
end
@impl true
def handle_call({:spawn_agent, agent_name, agent_config, opts}, _from, state) do
if Map.has_key?(state.agents, agent_name) do
{:reply, {:error, :already_exists}, state}
else
session_id = "team:#{state.team_id}:#{agent_name}"
registry_key = {:team, state.team_id, agent_name}
# Build deps with team context
base_deps = Map.get(agent_config, :deps, %{})
team_deps =
Map.merge(base_deps, %{
team_id: state.team_id,
team_name: state.team_name,
team_role: Keyword.get(opts, :role),
shared_state_pid: state.shared_state,
rate_limiter_pid: state.rate_limiter,
team_coordinator_pid: self(),
agent_name: agent_name
})
enriched_config = Map.put(agent_config, :deps, team_deps)
child_spec =
{Nous.AgentServer,
session_id: session_id,
agent_config: enriched_config,
pubsub: state.pubsub,
name: Nous.AgentRegistry.via_tuple(registry_key),
inactivity_timeout: :infinity}
case DynamicSupervisor.start_child(state.agent_supervisor, child_spec) do
{:ok, pid} ->
ref = Process.monitor(pid)
agents = Map.put(state.agents, agent_name, %{pid: pid, status: :running})
monitors = Map.put(state.monitors, ref, agent_name)
Logger.info("Spawned agent '#{agent_name}' in team '#{state.team_name}'")
Comms.broadcast_team(state.pubsub, state.team_id, {:agent_joined, agent_name})
{:reply, {:ok, pid}, %{state | agents: agents, monitors: monitors}}
{:error, reason} = error ->
Logger.error(
"Failed to spawn agent '#{agent_name}' in team '#{state.team_name}': #{inspect(reason)}"
)
{:reply, error, state}
end
end
end
@impl true
def handle_call({:stop_agent, agent_name}, _from, state) do
case Map.get(state.agents, agent_name) do
nil ->
{:reply, {:error, :not_found}, state}
%{pid: pid} ->
DynamicSupervisor.terminate_child(state.agent_supervisor, pid)
{state, _} = remove_agent(state, agent_name)
Comms.broadcast_team(state.pubsub, state.team_id, {:agent_left, agent_name})
Logger.info("Stopped agent '#{agent_name}' in team '#{state.team_name}'")
{:reply, :ok, state}
end
end
@impl true
def handle_call(:list_agents, _from, state) do
agents =
Enum.map(state.agents, fn {name, info} ->
%{name: name, pid: info.pid, status: info.status}
end)
{:reply, agents, state}
end
@impl true
def handle_call(:team_status, _from, state) do
agents =
Enum.map(state.agents, fn {name, info} ->
%{name: name, pid: info.pid, status: info.status}
end)
status = %{
team_id: state.team_id,
team_name: state.team_name,
agent_count: map_size(state.agents),
agents: agents
}
{:reply, status, state}
end
@impl true
def handle_call(:dissolve, _from, state) do
Logger.info("Dissolving team '#{state.team_name}' (#{state.team_id})")
# Stop all agents
for {_name, %{pid: pid}} <- state.agents do
DynamicSupervisor.terminate_child(state.agent_supervisor, pid)
end
# Demonitor all
for {ref, _name} <- state.monitors do
Process.demonitor(ref, [:flush])
end
Comms.broadcast_team(state.pubsub, state.team_id, {:team_dissolved, state.team_id})
{:reply, :ok, %{state | agents: %{}, monitors: %{}}}
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
case Map.get(state.monitors, ref) do
nil ->
{:noreply, state}
agent_name ->
Logger.warning(
"Agent '#{agent_name}' in team '#{state.team_name}' went down: #{inspect(reason)}"
)
Comms.broadcast_team(
state.pubsub,
state.team_id,
{:agent_crashed, agent_name, reason}
)
{state, _} = remove_agent(state, agent_name)
state = %{state | monitors: Map.delete(state.monitors, ref)}
{:noreply, state}
end
end
@impl true
def handle_info(_msg, state), do: {:noreply, state}
# Private
defp split_gen_opts(opts) do
case Keyword.pop(opts, :name) do
{nil, rest} -> {[], rest}
{name, rest} -> {[name: name], rest}
end
end
defp remove_agent(state, agent_name) do
{removed, agents} = Map.pop(state.agents, agent_name)
# Remove monitor by finding the ref for this agent
monitors =
state.monitors
|> Enum.reject(fn {_ref, name} -> name == agent_name end)
|> Map.new()
{%{state | agents: agents, monitors: monitors}, removed}
end
end