Skip to main content
A deep dive into the architecture and formal behavior of the turn-based, self-selecting GroupChat module.

Overview

The GroupChat 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:
All 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:
The agent returns a pair — a score in [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):
The rebuild via 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:
A raised exception — a bad 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:
Three exact, code-level facts fall out of this:
  1. Empty replies never win, regardless of score — if not reply: continue is checked before anything else.
  2. The bar is strict. best_adjusted starts at self.threshold, and only a bid with adjusted > best_adjusted overwrites it. A score that exactly equals threshold never wins, and ties keep whichever agent was checked first — selection is a deterministic function of self.agents order, not random tie-breaking.
  3. The winner’s raw score is what gets posted, not the recency-adjusted one — the tuple stored is (agent, score, reply), using the unadjusted score. recency_penalty only 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

The seed task is posted with 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:
Proposition (message count is bounded, deterministically). The seed counts as message 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:
  1. The hard cap. message_count reaches max_loops and the while condition fails. max_loops counts the seed, so at most max_loops - 1 agent turns can occur.
  2. A bidding lull. _select_speaker returns None for a turn — no agent’s recency-adjusted, non-empty bid cleared threshold. The loop breaks immediately, regardless of how far message_count is 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
A room tuned so 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 ValueError at construction (GroupChat requires at least 2 agents.).
  • Let auto_equip do its job, or equip agents yourself. An agent without RESPOND_TOOL in tools_list_dictionary will bid (0.0, "") every turn — permanently silent — because _extract_args can’t parse a non-tool-call response into a decision.
  • idle_timeout does nothing. Don’t tune it expecting to control when the chat stops; only max_loops and the bidding lull do that.
  • max_loops counts the seed. A chat configured with max_loops=10 gets at most 9 agent turns.
  • Raise threshold for a more selective, shorter-running room; raise recency_penalty to force rotation — but know that a high recency_penalty can itself end the chat early by turning a would-be winner into a lull.
  • run_batch shares one conversation across tasks. Build a new GroupChat per 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_async specifically logs a loud warning if no agent produces any reply on turn one, calling out a bad model_name, a missing API key, or a model without function-calling support as likely causes. Run with verbose=True to 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_history if 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 as User. 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.