GroupChat module.
Overview
TheGroupChat module (swarms/structs/groupchat.py) implements a
turn-based, self-selecting group conversation among autonomous language-model
agents. Unlike round-robin schemes, where an orchestrator picks who talks next
from a fixed rotation, GroupChat has no fixed speaking order. Every turn,
every agent privately rates how much it wants to reply; the single agent with
the highest (recency-adjusted) desire above a threshold takes the floor, and
only its message is posted. Everyone else stays silent for that turn.
The class docstring states this precisely: “Each turn every agent privately
bids on whether to speak; the single highest (recency-adjusted) bidder above
threshold takes the floor and its reply is the only message posted.” This
document explains exactly how that bidding, selection, and termination work,
grounding every claim in the function that implements it, and closes with a
complete, runnable program against the real constructor.
The design goal is worth stating plainly. Most multi-agent chat frameworks
impose coordination from the outside: a controller computes a speaking order,
or a manager agent nominates the next speaker. GroupChat instead asks every
agent, every turn, “do you want the floor?” and lets the room self-select one
speaker. It mirrors human turn-taking — everyone listens, the most motivated
or relevant participant jumps in, the rest stay quiet unless they have
something better to add next time.
Architecture
Execution is sequential, not an actor system
GroupChat runs on a single event loop and has exactly one shared mutable
piece of state: self.conversation, a plain Conversation object created
once in __init__. There are no per-agent mailboxes and no background
monitor task. The module’s own comment on _post says it directly: “There
are no per-agent inboxes: every agent reads the same Conversation when it
builds its next bid, so a single append makes the message visible to
everyone the following turn.”
The only concurrency in the whole runtime is within a single turn, and it
exists purely to hide LLM latency, not to let agents post independently. Each
turn, in _collect_bids, every agent’s decision call is dispatched to a
worker thread and awaited together:
N decisions run in parallel so one slow model call doesn’t stall the
turn, but the function returns a plain list of (agent, score, reply) bids
back to the single coroutine driving the loop. Exactly one of them is ever
posted (see _select_speaker below). Because every mutation of
self.conversation happens on that one coroutine — never inside a worker
thread — there is no race to guard against and no lock anywhere in the
module.
The respond protocol
The central design problem is making a speaking decision machine-readable.GroupChat forces every agent to emit a structured decision through a
function-calling schema, RESPOND_TOOL:
[0, 1] for how much it wants to
speak, and the message it would post. Forcing a function call rather than
parsing prose guarantees a typed payload, separates the decision (score)
from the content (message), and gives the model a low-friction way to
abstain by returning an empty string.
_ensure_respond_tool auto-injects this schema into any agent missing it,
gated by the auto_equip constructor flag (default True):
agent.llm_handling() is necessary because the agent’s LLM
client bakes its tool list in at construction time; appending to
tools_list_dictionary afterwards would otherwise have no effect until the
client is regenerated. If auto_equip=False and an agent never receives
RESPOND_TOOL some other way, its replies won’t parse as a tool call and it
will bid (0.0, "") — silent — every turn (see _extract_args below).
The decision prompt, GROUPCHAT_DECIDE_PROMPT, is deliberately biased toward
silence: “Silence is the default — most messages do NOT warrant a reply from
you.” It scores high only for direct expertise, being addressed by name, a
correctable error, or a concrete next step, and scores low for
off-topic remarks, redundant points, filler agreement, or speaking again
right after having just spoken.
One decision call per agent, with full typed history
_decide_sync is what each worker thread actually runs. It formats the
decide prompt with the latest posted message, then calls the agent with the
entire shared conversation rendered as typed chat turns (the agent’s own
prior posts become assistant turns, everyone else’s become user turns) via
messages_for:
model_name, a missing API key, a model without
function-calling support — is caught and degraded to the silent bid
(0.0, "") rather than crashing the turn. That makes a broken agent
indistinguishable, from the outside, from an agent that simply chose not to
speak — which is exactly why _run_async special-cases the very first turn
(below) to warn loudly if every agent stays silent immediately.
_extract_args is the total function that turns raw provider output into a
clean (score, message) pair. It handles the shapes different providers
return — a bare dict, a list of tool calls, a stringified repr, a pydantic
object — and on any unparseable input falls back to the same silent decision
(0.0, ""), clamping any parsed score into [0, 1] and stripping the
message.
_select_speaker: a strict, recency-adjusted argmax
This is the only place that decides who speaks:
- Empty replies never win, regardless of score —
if not reply: continueis checked before anything else. - The bar is strict.
best_adjustedstarts atself.threshold, and only a bid withadjusted > best_adjustedoverwrites it. A score that exactly equalsthresholdnever wins, and ties keep whichever agent was checked first — selection is a deterministic function ofself.agentsorder, not random tie-breaking. - The winner’s raw score is what gets posted, not the recency-adjusted
one — the tuple stored is
(agent, score, reply), using the unadjustedscore.recency_penaltyonly affects who wins, never the score value later attached to the posted message’s metadata.
recent is a set built from a deque(maxlen=max(1, self.recency_window))
of the last speakers’ names, so recency_window <= 0 still behaves like a
window of 1 — the only way to fully disable the rotation effect is
recency_penalty=0.0.
A direct consequence of the arithmetic: for an agent to win two turns in a
row (with the default recency_window=1), its raw score on the second turn
must satisfy score > threshold + recency_penalty, not just score > threshold. The penalty raises the bar specifically for whoever just spoke,
which is what keeps the floor moving around the room instead of one agent
monopolizing it.
_post: one append, optionally chunked to a callback
score=None; every agent turn is posted with
its raw bid score. verbose=True also prints each posted message as a panel.
Because a turn’s reply is generated atomically inside the bid call — the
whole message already exists before _post runs — streaming_callback
can’t stream real tokens. _stream_reply instead chunks the finished text on
whitespace and replays it word-by-word, ending with an is_final=True
sentinel, so callers get the same (agent_name, chunk, is_final) streaming
signature used by SequentialWorkflow and AgentRearrange.
The turn loop and its two termination conditions
_run_async is the entire runtime:
1. The while guard is message_count < self.max_loops,
and the only way message_count changes is += 1, exactly once, on a turn
that produces a winner; every other path is break. So for any single call
to run(), the number of posted messages |H| satisfies
1 <= |H| <= self.max_loops, with equality on the upper bound only if every
turn up to the cap produced a winner. This follows directly from the loop
structure — no timing or probability argument is needed.
There are exactly two ways the loop ends, and both are real, current code
paths:
- The hard cap.
message_countreachesmax_loopsand thewhilecondition fails.max_loopscounts the seed, so at mostmax_loops - 1agent turns can occur. - A bidding lull.
_select_speakerreturnsNonefor a turn — no agent’s recency-adjusted, non-empty bid clearedthreshold. The loopbreaks immediately, regardless of how farmessage_countis from the cap.
idle_timeout plays no role in either path. The constructor keeps the
parameter and documents it as “Deprecated/unused — the chat now ends on a
bidding lull rather than a wall-clock timeout. Kept for compatibility.” It is
never read anywhere in _run_async, _select_speaker, or _post.
How recency_penalty can trigger termination condition 2 on its own.
Because the bar in _select_speaker is applied to the adjusted score, a
turn can go from “someone wants to speak” to a lull purely because of who
spoke last. If the only agent whose raw score clears threshold is also in
recent, and raw_score - recency_penalty <= threshold, then _select_speaker
returns None even though a raw bid existed above the bar. Raising
recency_penalty therefore does two things at once: it forces rotation among
speakers, and it makes lulls (termination condition 2) more likely whenever
only one agent currently has something to say.
How threshold shapes both the speaker distribution and termination.
Raising threshold shrinks the set of bids that can ever win a turn, which
has two effects that follow directly from the code: fewer agents qualify to
speak at all (a more selective room), and a lull (condition 2) becomes more
likely on any given turn, since more turns will have no bid clearing the
raised bar. There is no branching or fan-out to reason about — each turn
independently checks the same adjusted > threshold condition.
A simplifying model for expected conversation length
This is presented as an approximation for intuition, not a claim about the code’s exact joint distribution —recency_penalty and the evolving
transcript make consecutive turns dependent on each other, and the real bid
distribution depends on the LLM.
If we idealize each turn after the seed as an i.i.d. Bernoulli trial that
“succeeds” (produces a winner) with some fixed probability q — the
probability that at least one agent’s adjusted, non-empty bid clears
threshold — then the number of successful turns before the first lull
follows a geometric distribution, truncated at max_loops - 1 turns by the
hard cap. Under that idealization, the expected number of posted messages is
approximately
q is small (a high threshold, or a decide prompt biased
toward silence, which GROUPCHAT_DECIDE_PROMPT already is) ends quickly on
its own via a lull. A room tuned so q is close to 1 will tend to run all
the way to max_loops, since a lull becomes rare. This matches the two real
termination conditions exactly — it’s just a way to reason about which one
is likely to fire first for a given configuration.
Constructor reference
GroupChat.__init__ accepts exactly these parameters:
run(task, streaming_callback=None) runs one conversation synchronously
(asyncio.run(self._run_async(...))) and returns the transcript formatted
per output_type.
run_batch(tasks) calls batched_run(self.run, tasks), which — with no
max_workers passed — runs the tasks sequentially, one full run() call
after another. This matters beyond throughput: self.conversation is
created once in __init__ and is never reset between calls to run().
Every task in a batch is posted into the same growing Conversation
object, so agents deciding on task 2 will see the full transcript of task 1
in their history (via messages_for) as well. message_count itself is a
local variable that resets to 1 on every _run_async call, so max_loops
still caps each task’s own turns — but the conversational context is not
isolated between tasks. Construct a fresh GroupChat per task if isolation
is required.
Practical implications
- Provide at least two agents. Fewer raises
ValueErrorat construction (GroupChat requires at least 2 agents.). - Let
auto_equipdo its job, or equip agents yourself. An agent withoutRESPOND_TOOLintools_list_dictionarywill bid(0.0, "")every turn — permanently silent — because_extract_argscan’t parse a non-tool-call response into a decision. idle_timeoutdoes nothing. Don’t tune it expecting to control when the chat stops; onlymax_loopsand the bidding lull do that.max_loopscounts the seed. A chat configured withmax_loops=10gets at most 9 agent turns.- Raise
thresholdfor a more selective, shorter-running room; raiserecency_penaltyto force rotation — but know that a highrecency_penaltycan itself end the chat early by turning a would-be winner into a lull. run_batchshares one conversation across tasks. Build a newGroupChatper independent task unless carrying context between tasks is intended.- A silent room on the very first turn usually means misconfiguration, not
a design choice.
_run_asyncspecifically logs a loud warning if no agent produces any reply on turn one, calling out a badmodel_name, a missing API key, or a model without function-calling support as likely causes. Run withverbose=Trueto see each bid. - The metadata score on a posted message is the raw bid, not the
recency-adjusted score that actually won the turn — inspect
chat.conversation.conversation_historyif you need the exact value that determined selection versus the value stored for display.
Complete worked example
The following program builds a four-agent room, runs a discussion, and inspects the result. It is fully runnable once an LLM API key is set in the environment.What to expect when you run it
The seed task is posted asUser. Every turn, all four agents privately bid
through the forced respond call; _select_speaker picks the single highest
recency-adjusted bidder above 0.6 and posts only that reply, which then
becomes the “latest message” the next turn’s bids are formed around. Because
the decide prompt defaults to silence and recency_penalty=0.3 discourages
back-to-back turns from the same agent, expect the floor to move between two
or three of the four agents over a handful of turns before the room hits a
lull — no bid clears 0.6 — and run() returns. If the panel stays
contentious enough that some agent keeps clearing the threshold, the chat
instead runs until max_loops=12 is reached, the hard cap.
Summary
GroupChat runs a single-event-loop, turn-based loop with no per-agent
mailboxes, no monitor task, and no lock — the only concurrency is gathering
one turn’s bids in parallel via asyncio.to_thread before the single
coroutine driving _run_async posts at most one winner (_select_speaker,
a strict recency-adjusted argmax over non-empty bids). The loop is bounded
deterministically by max_loops and can end earlier at any bidding lull
where no adjusted score clears threshold; idle_timeout is accepted for
compatibility but does nothing. threshold and recency_penalty jointly
shape both who gets to speak and how likely a lull is on any given turn,
including the case where the penalty alone turns a would-be winner into a
lull. run_batch runs tasks sequentially against the same unreset
self.conversation, so batched tasks share transcript context unless a new
GroupChat is constructed per task.