Discussion: Agents in a Group Chat¶
Several agents and one or more people hold one conversation in a room. Any
agent may address any other with @channel_id, a person may address any
agent, and nobody fixes the order in advance: who speaks next follows from who
was addressed. One agent speaks at a time (RFC §19.7.5); long work runs as
tasks, apart from the conversation. An agent is an AI channel or an external
agent over ACP, such as Claude Code.
from roomkit import Agent, Discussion, RoomKit
investigator = Agent("investigator", provider=..., role="Investigator",
description="Reads logs and metrics first")
dev = Agent("dev", provider=..., role="Developer",
description="Knows the code and the releases")
sre = Agent("sre", provider=..., role="Site reliability engineer",
description="Runs production changes")
kit = RoomKit()
room = await kit.create_room(orchestration=Discussion(
agents=[investigator, dev, sre],
everyone=["investigator", "dev", "sre"],
people=["oncall"],
))
A runnable version, with scripted models and no keys, is
examples/discussion_group_chat.py.
Why a strategy¶
A room of several agents left to the default policy dogpiles: every agent
answers the same message at once, each reading the room as it was before the
others answered, and every answer solicits the others again.
AgentResponsePolicy.ADDRESSED_ONLY stops the dogpile by stopping agents from
answering each other at all. A discussion keeps them talking and takes the
turns itself.
Swarm passes the conversation from one agent to the next: the active agent talks with the user and the others wait for a handoff. In a discussion every agent stays in the conversation and speaks when it is addressed, the way people in a group chat do.
Who speaks next¶
The strategy keeps a speak queue per room. A committed message queues the agents it asks for; the strategy gives the next turn once the current one has ended.
| Message | Asks |
|---|---|
A person's message naming agents (@dev @sre ...) |
Those agents, at the front of the queue, in the order named |
| A person's message naming nobody | The agents that asked that person, else everyone, in its order; with a dispatch policy, the agents it picks |
| A person's message naming only people | Nobody |
| An agent's answer naming agents | Those agents, at the back, once the turn has ended |
Any other sender's message (a bot, a webhook, kit.deliver()) |
The agents its address names, at the back |
@allnames every agent. A name holds letters of any script, digits,_ . -. Names are read in what the speaker wrote once every host hook has run, never in a fenced code block or a quoted (>) line, and they set the event'saddressed_to. A name never gets around visibility: an agent that cannot read the message is not queued by it.- An agent named several times while queued is owed one turn; it answers the latest person's message among the events that asked for it.
- The agent that just spoke does not take the next turn while another agent of the queue can.
everyone's order is yours: put first the agent that should look at the facts before the others act. The discussion does not rotate it.
Rooms where people talk among themselves¶
With addressed_only=True, a person's message asks only the agents it names:
people who talk together in the room are never cut into by an agent. To let
agents judge for themselves whether an unaddressed message is for them, leave
addressed_only off and give them a speak policy, or give
the discussion a dispatch policy, which judges once for the room (below).
Who takes a message that names nobody¶
A person's message that names no agent goes to the agents that asked that
person a question, else to everyone, one turn each. In a room of
specialists most of those turns end in (silent), the agent that should
answer may come third, a "thanks" wakes the whole team, and an agent that
just asked "@ops did anyone change a flag?" takes your next message even when
it is "Customers are writing to support, what are they saying?". A
dispatch policy decides instead which agents take it, in which order, or
that none does (RFC §19.7.5 rule 18):
from roomkit import Agent, ClassifierDispatchPolicy, Discussion, JevClassifier
team = [
Agent("investigator", provider=..., name="Investigator",
description="reads checkout-api's logs and request traces"),
Agent("sre", provider=..., name="SRE",
description="reads production metrics and the deploy history, can roll back"),
Agent("dev", provider=..., name="Developer",
description="reads the feature-flag history and the source code"),
Agent("comms", provider=..., name="Comms",
description="reads the support tickets, owns the public status page"),
]
await kit.create_room(orchestration=Discussion(
team,
dispatch=ClassifierDispatchPolicy(JevClassifier(), threshold=0.5, max_agents=2),
))
ClassifierDispatchPolicy asks a classifier, in one call,
whether each agent should take the message, from each agent's name and
description, the recent conversation and the message. The agents whose
probability reaches threshold take it, likeliest first, at most
max_agents; none reaching it, no agent does.
On eight unaddressed messages to these four agents
(examples/discussion_dispatch.py), the same six answers came from:
| Turns | Silent turns | Time per decision | |
|---|---|---|---|
No policy (everyone) |
32 | 26 | — |
| Jev (TypeSafe) | 8 | 2 | 107–280 ms |
Claude Haiku (LLMClassifier) |
7 | 1 | 1.2–2.9 s |
What a policy decides, and what it cannot:
- Only a message that names nobody. A name is the person's word and is
never decided.
addressed_onlyand a dispatch policy are exclusive (the constructor refuses both). - Knowing who waits for an answer. The agents that asked the person come
first among the candidates and are listed in
DispatchTurn.asked;ClassifierDispatchPolicymarks them, so "eu-west" goes to the agent that asked which region, and a new request goes to whoever it is for (10 right out of 10 on Jev with the mark, 9 without). - Among the candidates. They are the agents that asked the person, then
everyone, in its order, less the agents the message does not reach (visibility, access) and those that only listen. A decision naming anyone else is cut down to them. - Once, at the message's place. The process holding the lease decides off the room lock, before it gives another turn. The agents picked go to the front in the order decided, still before the agents a later message asks for. Each still answers through its own speak policy, if it has one.
- Never silencing the room. A policy that raises, takes longer than
dispatch_timeout(5 s) or returns no readable decision leaves the message to the rule without a policy (the agents that asked the person, else every candidate), reported with the reasonfallback. At most 16 messages wait for a decision (none is decided while no process that installed the discussion is alive); past them, the oldest goes the same way. - Not stored with the message. Its
addressed_tostays null, and a regenerated answer with none left askseveryone.
Every decision applied fires ON_DISPATCH_DECISION, once per message, with a
DispatchDecisionEvent:
the message, the candidates, the DispatchDecision (agents, reason,
judgments) and duration_ms.
@kit.hook(HookTrigger.ON_DISPATCH_DECISION, execution=HookExecution.ASYNC)
async def log_dispatch(event: DispatchDecisionEvent, ctx) -> None:
logger.info("%s -> %s (%s, %d ms)", event.event.id, event.decision.agents,
event.decision.reason, event.duration_ms)
A policy of your own implements DispatchPolicy.decide(turn): a
DispatchTurn carries the message, the conversation before it that a
candidate may read, who said what (speakers) and the candidates with their
identity (DispatchCandidate), and which of them wait for the person's
answer (asked). MockDispatchPolicy scripts decisions for tests.
What a turn reads¶
Each turn is a rerun of the event it answers, planned for one agent and run in
the room's delivery lane. Its context is built when the turn starts, so what
was said while the agent waited is in it, and the event it answers keeps its
place in the history instead of being read as the last message. The turn's
notes tell the agent who else is in the room (their channel ids, with the
name, role and description of Agents and of external agents given one),
whom it may address, who asked for this turn, and how the room works.
A speak policy is consulted on a turn as on any event the agent would answer.
Without one, the model may stay silent itself by answering the silence token
(silent_token, default "(silent)", read ignoring case, a final period and
its brackets). That row is stored BLOCKED with
blocked_by="discussion_silent", delivered nowhere and read for no name. A
streamed answer is held back while its start could still be the token, so a
live transport never sees it.
process_inbound() returns once the message is committed and broadcast, with
no agent's answer in its result: the answers come in the turns that follow.
Each agent's memory provider is still handed every message the agent may see (not its own), as it commits, as in a room with no discussion: a memory that learns as messages arrive (a retrieval index, a summary) learns the whole conversation, not only the messages that asked for the agent.
Waiting for a person¶
When an agent names a person (@oncall may I roll back?), the discussion
records that it asked them. When no agent of the queue can take a turn, the
discussion waits for a person if it owes one an answer, if an agent of the
queue only listens, or if the depth limit stopped the next turn. A person's
next message ends the wait; naming nobody, it answers the agents that asked
that person.
Agents address people by the names the transcript gives them: a participant's
name, the name a transport stamped (sender_name), else the channel of a
sender with no name (@sms1), kept to an identifier's characters (Alice
Martin is addressed as @AliceMartin) and at most 32 of them. people, when
set, keeps only the names it lists. What an agent asked is recorded against
the person as the transcript labels them, and only when one person answers to
the name: a name two people answer to records nothing, and a sender who takes
another's name (labelled Alice (2)) never answers for them.
Only a room with a person waits: a room whose only other senders are bots and webhooks stays idle instead, and the next event that asks for a turn opens it again.
Depth, turns and the end¶
| Option | Effect |
|---|---|
max_depth |
How far a chain of agent turns reaches from a person's message (default: the kit's max_chain_depth; 5 allows four hops). A turn past it is recorded once (event_chain_depth_limit), its agent stays queued, and the next person's message opens a new chain. |
max_turns |
Turns given in the room's lifetime; the discussion is over after them. |
done(room_id) |
Sync or async, checked before each turn: once it holds, the discussion is over. A mission's state, a person's word, a budget. |
Once over, the discussion gives no further turn and drops its queue; an
instruction addressed to an agent is refused with
InboundResult(blocked=True, reason="discussion_over"), and later messages
are stored and delivered with no agent asked. To have the agents answer again,
uninstall the discussion.
Listening only¶
await kit.listen_only(room_id, ["sre"]) # a console's "@sre hold on"
await kit.talk_again(room_id, ["sre"]) # "@sre you can carry on"
An agent that only listens keeps its place in the queue and takes no turn an
agent asks for; a person's message that names it gives it one turn, and it goes
on listening (a message that names nobody gives it none). Setting it on the
agent whose turn runs cuts that turn, as a Cancel steering directive does:
what the turn committed stays in the room; a turn given but not running yet is
abandoned before it runs. talk_again also ends a wait that was for that
agent.
Instructions and regenerated answers¶
An INSTRUCTION addressed to agents queues each of them at the front for a
turn of its own that takes the instruction as its input; one addressed to no
agent of the discussion is reported in unavailable_targets.
kit.regenerate_response(room_id) queues, at the front, a turn of its own for
each agent that answered the last person's message (each agent the message
asked for, when you removed the answers first), once per agent and message.
Both are given even while the discussion waits for a person, to an agent that
only listens, and to the agent that just spoke. Past the depth limit, an
instruction's turn is dropped and reported, a regenerated answer's recorded
and dropped. Once the discussion is over, both are refused with
discussion_over.
Talking and working: tasks¶
A turn talks; long work runs as a task, off the floor. An agent asked to go through every support ticket used to hold the floor for the whole of it, and a quick question to another agent waited behind. With background work set up, it starts the work as a task, says in a sentence that it is on it, and its turn ends: the room goes on, and the result comes back when the work is done.
kit.setup_background(comms) # comms does its own work
kit.setup_background(dev, worker="dev-code") # Claude Code over ACP does dev's
kit.setup_background(sre, hand_back="post") # sre's results posted as they are
setup_background gives the agent the work_in_background tool. A task runs
on the agent's worker (the agent itself, or any intelligence channel the kit
holds: another agent, an external agent over ACP), in a child room, handed the
room's last 12 messages, as any delegation
(Agent Delegation). Tasks run in parallel with the
turns and with one another; a question asked while a task runs is answered in
its turn, not after the task.
The result comes back as the host set it up:
hand_back |
What happens |
|---|---|
"relay" (default) |
The agent is told the result and says it in a turn of its own, in the conversation's words, at the front of the queue. |
"post" |
The result is posted as it is, as the agent's message, with no generation: at once, and as the worker wrote it. It waits at the front of the queue for the turn under way to end, and the names it carries ask no one. |
Relay a result that needs putting in context; post one that is long or exact (an analysis, a list, a diff), or that a slow model would hold for the length of a turn.
A model may still do long work in its own turn whatever its tool says (in our runs, Mistral Large 4 never used it). The host, or a person through it, starts the task itself:
task = await kit.start_task(room_id, "dev", "Read the tax engine's source: why do "
"Canadian checkouts fail?", hand_back="post")
await kit.cancel_task(task.id) # the agent says it was cancelled
start_task works for any agent of the room, an external agent included,
which takes no tool from RoomKit. Each turn's notes list the room's tasks, so
an agent asked how a task goes answers from them; task_status and
cancel_task are tools you may give it as well. A worker never starts a task
of its own: the tool is refused in a task's child room. A task outlives the
discussion: a result that comes back once it is over or uninstalled is posted
at once, or relayed as any instruction (refused once the discussion is over).
External agents (ACP)¶
An external agent over ACP takes the discussion's turns as an AI channel does:
from roomkit import ACPChannel
dev = ACPChannel(
"dev",
command=["npx", "--no-install", "@agentclientprotocol/claude-agent-acp@0.61.0"],
cwd="/srv/checkout",
name="Developer",
description="reads the source code and the support tickets",
instructions="You are @dev, a developer in an incident room. Answer briefly.",
)
room = await kit.create_room(orchestration=Discussion(agents=[investigator, dev, sre]))
Its prompt carries the turn's notes, under the runtime's header (a copy of
that header in what participants wrote is replaced); its name, role and
description present it to the others, to the dispatch policy and to the
console; instructions are sent once per session, since its system prompt is
not RoomKit's to set. Its session keeps its own history: a turn sends what the
agent has not read yet, then the message it answers. listen_only on it while
its turn runs cancels its prompt. Its tools are its own (its workspace, what
its agent accepts): the room's tools are not wired into it. A turn of a coding
agent can take tens of seconds; long work is better started with
start_task, which runs it in a session of its own while the room goes on.
Following the queue¶
from roomkit import HookExecution, HookTrigger, SpeakQueueEvent
@kit.hook(HookTrigger.ON_SPEAK_QUEUE, execution=HookExecution.ASYNC)
async def follow(event: SpeakQueueEvent, ctx) -> None:
queue = event.queue
print(event.change, event.channel_ids, queue.speaking, queue.queue, queue.waiting)
kit.speak_queue(room_id) returns the SpeakQueue at any time. It, and
listen_only / talk_again, take organization_id to scope the call to one
tenant: a room of another organization reads as holding no discussion.
| Field | Meaning |
|---|---|
speaking |
The agent whose turn runs now |
queue |
Agents owed a turn, in the order they get it |
listening |
Agents that only listen |
asked |
(agent, person) pairs: agents waiting for a person's answer |
waiting |
Whether the discussion waits for a person |
over |
Whether the discussion is over |
ON_SPEAK_QUEUE fires for each change, in the order the changes happened:
queued, turn_given, turn_ended, instruction_dropped, listening,
talking_again, waiting (the wait beginning or ending: read
event.queue.waiting), over.
A console for the discussion¶
DiscussionConsole (pip install roomkit[console]) is a full-screen terminal
on a room a discussion holds: the room on the left (every message and tool
call, who asks you), a card per agent on the right (identity, model, live
state: speaking, next, listening, asking, nothing to add), the speak queue
below, your input at the bottom.
from roomkit import WebSocketChannel
from roomkit.console import DiscussionConsole
kit.register_channel(WebSocketChannel("you"))
await kit.attach_channel(room_id, "you")
await DiscussionConsole(kit, room_id, channel_id="you", log_file="room.log").run()
What you type goes into the room through the transport channel you name.
/listen @a and /talk @a set the listening state, /help and /quit do
what they say, and commands={"name": handler} adds your own. Only messages
from your channel and sender show as yours; every other author shows under the
transcript's label. The kit's logs go to log_file while the screen is up
(without one, warnings are held and printed once it closes). Pass cards to
present the agents your way (AgentCard: name, role, description, model,
tools). A runnable version, with three Claude agents, is
examples/discussion_console.py.
With a WebSocket channel as yours, the answer an agent is writing shows as it
streams, under "writing…", until it is said. When an agent of the room has
background work set up (or with tasks=True), /task @dev <what to do>
starts a task for it, /tasks lists the room's tasks and /cancel <task>
stops one (six hex digits of its id are enough); each agent's card shows its
running tasks, and a result posted as an agent's message names the worker
that wrote it (@dev · a task's result, by dev-code).
The room is the discussion's¶
One rule decides who speaks, never two. Installing a discussion refuses a room
that binds another intelligence channel or a voice or realtime channel, an
agent that thinks while it listens, a ConversationRouter, or another
strategy. While it is installed, binding such a channel, installing a router
or another strategy in the room is refused too. Transports attach as usual.
The room's agent_response_policy plays no part while the discussion is
installed:
await kit.install_strategy(room_id, Discussion(agents=[investigator, dev, sre]))
await kit.uninstall_strategy(room_id) # the room's policy answers again
A room holds one strategy at a time; it can start as a plain chat, take a discussion when a team joins, and become a swarm later (see Installing a strategy while the room lives).
State and processes¶
The discussion's configuration (_discussion) and speak queue (_speak_queue)
are stored in the room's metadata and outlive a restart: call
strategy.install(kit, room_id) again in the new process.
Several processes may serve one room, behind a load balancer, on one store
(PostgresStore with PostgresAdvisoryLockManager):
- every process follows the stored discussion, even one whose host did not install it there: it asks no agent at broadcast, reads names and queues turns, each change of the queue read and written under the room lock;
- one process that installed the discussion gives the turns, under a lease stored with the queue (15 s, renewed while held); another such process takes over once it expires, so a crash stalls the room 15 s at most;
- the holder reads the queue again every second, which carries another
process's queued turn, or its
listen_only, to it; - an instruction's turn moves the lease to the process holding its text.
| Situation | What happens |
|---|---|
| A message reaches a worker that did not install the discussion | It is queued; the lease holder gives the turns, one at a time |
| Two messages reach two workers at once | One queue, one holder: one agent after the other |
| The worker giving the turns crashes | Its lease expires; another worker that installed the discussion takes over |
Uninstalling, in any process, forgets the discussion in all of them. A
runnable version with two simulated workers is
examples/discussion_two_workers.py.
Limits of this version¶
- Text only: voice and realtime channels and agents that think while they listen are refused.
- One turn at a time: work that does not need the floor runs as tasks, but turns never run in parallel.
- An external agent's tools are its own: the room's tools are not wired into it.
- Text an agent writes between tool rounds reaches the room as it is written; only the names it carries wait for the turn's end.
- One queue per room: threads (
parent_event_id) are not yet conversations of their own.
Related guides¶
| Guide | Description |
|---|---|
| Multi-Agent Orchestration | The other strategies, addressing and agent response policies |
| Speaking Turns | Speak policies: an agent that decides whether to answer |
| Classifiers | Jev and LLM classifiers, the judgments a dispatch policy rests on |
| AI Steering Directives | Cancel and the other directives |