Conversation Rooms

Deterministic, restart-safe turn-taking for a team of agents in one shared chat

A conversation room lets a roster of agents share one chat and reply on their own — you send a message, the room runs the scheduled turns in a deterministic, bounded, restart-safe order and hands you the replies.

Diagram

👤 User Message

🏠 Room.on_message

🧠 Planner

🤖 Rostered Agents

✅ Replies

Quick Start

Build a team of agents, name the roster, and send one message — the room runs every scheduled turn and returns the replies.

  1. Simple room

    Three agents share one chat. on_message fans out to the whole roster and returns their replies.

    python
    from praisonaiagents import Agent
    from praisonaiagents.rooms import Room, RoomConfig, RoomEvent
    
    researcher = Agent(name="researcher", instructions="Research the topic.")
    critic     = Agent(name="critic",     instructions="Critique proposals.")
    writer     = Agent(name="writer",     instructions="Write the final draft.")
    
    cfg = RoomConfig(name="standup", roster=["researcher", "critic", "writer"])
    room = Room(cfg, agents={
        "researcher": researcher.achat,
        "critic":     critic.achat,
        "writer":     writer.achat,
    })
    
    replies = await room.on_message(RoomEvent(speaker="user", content="plan the sprint @critic"))
    for turn in replies:
        print(f"{turn.speaker}: {turn.content}")
  2. Configure caps

    Bound the conversation with max_rounds and max_messages so a room can never loop unbounded.

    python
    from praisonaiagents.rooms import RoomConfig
    
    cfg = RoomConfig(
        name="standup",
        roster=["researcher", "critic", "writer"],
        max_rounds=2,
        max_messages=10,
    )
  3. Load from YAML / dict

    Build the config from a parsed mapping. Either roster: or agents: names the roster.

    python
    from praisonaiagents.rooms import RoomConfig
    
    cfg = RoomConfig.from_dict("standup", {
        "agents": ["researcher", "critic", "writer"],
        "max_rounds": 2,
    })
  4. Advanced: drive the planner yourself

    If you’re building a wrapper that persists its own transcript, drive plan_next directly instead of using Room.

    python
    from praisonaiagents.rooms import RoomEvent, RoundRobinRoomPlanner
    
    roster = ["planner", "coder", "reviewer"]
    transcript = [RoomEvent(speaker="user", content="build a login form", round=0)]
    
    planner = RoundRobinRoomPlanner()
    
    while (next_speaker := planner.plan_next(roster, transcript)) is not None:
        print("next up:", next_speaker)
        # Run the named agent, then append its reply below.
        transcript.append(RoomEvent(speaker=next_speaker, content="...", round=0))

User Interaction Flow

A real user builds a team and asks the room a question — no manual loop, no reset.

  1. Create Agent objects.
  2. Declare a RoomConfig naming the roster.
  3. Build a Room(cfg, agents=...).
  4. await room.on_message(RoomEvent(speaker="user", content="...")).
  5. The room fans out to round 0, honours any @-mentions for later rounds, and returns the replies.
  6. The next user message reopens a fresh activity automatically.

How a Room Runs

Room.on_message appends your event, drains the planner, runs each scheduled agent, and returns the replies.

Diagram
Rostered AgentPlannerRoom.on_messageUserRostered AgentPlannerRoom.on_messageUserloop[until plan_next returns None]on_message(event)transcript.append(event)plan_next(roster, transcript)next speaker (or None)reply(rendered transcript)contenttranscript.append(RoomEvent(speaker, content, round))List[RoomEvent] replies

The planner is pure, so persisting room.transcript and rebuilding the room is enough to resume — replaying a settled transcript returns no next speaker, so there is no double-execution.


Choosing a Configuration Surface

Pick where your room config lives.

Diagram

In code

YAML / dict

You’re building a wrapper
with durable storage

Where does your
room config live?

RoomConfig&(name=…, roster=…&)

RoomConfig.from_dict&(name, data&)

Drive RoundRobinRoomPlanner
and persist transcript yourself


Configuration Options

RoomConfig is a dataclass describing the roster and planner caps.

OptionTypeDefaultDescription
namestr— (required)Room identifier (e.g. room:standup). Must be non-empty, else ValueError.
rosterList[str][]Ordered agent ids that take turns.
plannerstr"round_robin"Planner selector. Only "round_robin" is valid; any other value raises ValueError.
max_roundsint3Hard cap on turn-taking rounds. Must be >= 1.
max_messagesint20Hard cap on messages per human-initiated activity. Must be >= 1.

RoomConfig methods:

  • build_planner() — constructs RoundRobinRoomPlanner(max_rounds=..., max_messages=...).
  • to_dict() — plain dict for YAML round-tripping or diagnostics.
  • RoomConfig.from_dict(name, data) — builds from a parsed mapping; accepts roster: or agents:. Rejects scalar values (bare string, bytes, mapping) with an actionable ValueError.

RoundRobinRoomPlanner is a dataclass with two hard caps.

OptionTypeDefaultDescription
max_roundsint3No turns are scheduled at or beyond this round. Must be >= 1.
max_messagesint20No turns once the activity reaches this many events (human + agent, counting passes). Must be >= 1.

RoomEvent is a frozen dataclass — immutable, hashable, and safe to persist or replay.

FieldTypeDefaultDescription
speakerstr—Agent id from the roster, or any non-roster id (e.g. a human participant).
contentstr""Message text. Scanned for @name addressing.
roundint0Turn-taking round this event belongs to.
passedboolFalseTrue means the speaker was scheduled but passed. Still consumes the turn.

For the full Room and RoomConfig API surface, see the auto-generated SDK reference.

Rooms SDK Reference

Every Room and RoomConfig parameter, method, and default.


Turn-Taking Rules

Round 0 — opening fan-out

After a human message, every rostered agent speaks once in roster order. This is the “everyone on round 0” default.

Later rounds — @-mention admission

A round N+1 admits only the agents that were @-mentioned by an agent during round N, and only agents actually on the roster. Mentions are honoured in roster order (not mention order). An agent speaks at most once per round.

Bounded — hard stops

max_rounds and max_messages are hard stops; a room can never loop unbounded.

Restart-safe — pure replay

The plan is a pure function of (roster, transcript). Rebuild a Room from a persisted transcript and plan_next returns the identical next speaker — no double-execution, no lost turn.


Current Activity Semantics

The planner scopes accounting to the slice of the transcript from the last human (non-roster) message onward.

  • A room only starts once a human (non-roster) message has been seen. Before that, plan_next returns None.
  • max_messages is per activity, not lifetime. A settled room reliably restarts on the next human message, no matter how long the historical transcript is.
  • @-mention admission is also scoped to the current activity — stale mentions from a prior settled activity do not leak into a new activity’s later rounds.

Mention Parsing

extract_mentions(text) returns the ordered, de-duplicated list of @name mentions found in text.

python
from praisonaiagents.rooms import extract_mentions

extract_mentions("@coder and @reviewer, then @coder again")
# ['coder', 'reviewer']  — first-seen order, de-duplicated

extract_mentions("email me at foo@bar.com")
# []  — a @ glued to a word character is not a mention

extract_mentions("")
# []
  • First-seen order is preserved so addressing is deterministic.
  • Case is preserved; matching against roster ids is case-sensitive.
  • Name grammar: @ followed by [A-Za-z0-9][\w\-.]* — letters, digits, underscore, dash, dot; a name may not start with _, -, or ..
  • Email-safe: a @ glued to a preceding word character does not match, so scanning human text is safe.

Common Patterns

Concurrent messages are safe

Two overlapping on_message calls do not interleave — each is serialised through an internal asyncio.Lock, so every human message opens exactly one round-0 activity and no agent double-executes for one activity.

Unknown roster id passes automatically

An id in roster with no matching agents entry records a passed=True turn so the planner advances instead of looping forever on a missing agent.

python
from praisonaiagents.rooms import Room, RoomConfig, RoomEvent

async def reply(prompt: str) -> str:
    return "on it"

cfg = RoomConfig(name="r", roster=["ghost", "real"])
room = Room(cfg, agents={"real": reply})  # "ghost" has no callable

replies = await room.on_message(RoomEvent(speaker="user", content="hi"))
# "ghost" records passed=True; "real" replies.
Custom render

Pass render=lambda transcript: ... to control the prompt handed to each agent. The default joins speaker: content lines.

python
from praisonaiagents.rooms import Room, RoomConfig

cfg = RoomConfig(name="r", roster=["a", "b"])
room = Room(
    cfg,
    agents={"a": a.achat, "b": b.achat},
    render=lambda transcript: "\n".join(f"[{e.speaker}] {e.content}" for e in transcript),
)
Custom planner

Pass planner=MyPlanner() for a domain-specific policy. The runtime tags each turn with the activity’s current round rather than inventing round-robin admission, so custom planners are never fed misleading round labels.

python
from typing import Optional, Sequence
from praisonaiagents.rooms import Room, RoomConfig, RoomEvent, RoomTurnPlannerProtocol

class FirstAgentPlanner:
    def plan_next(
        self,
        roster: Sequence[str],
        transcript: Sequence[RoomEvent],
    ) -> Optional[str]:
        return roster[0] if roster else None

assert isinstance(FirstAgentPlanner(), RoomTurnPlannerProtocol)

cfg = RoomConfig(name="r", roster=["a", "b"])
room = Room(cfg, agents={"a": a.achat, "b": b.achat}, planner=FirstAgentPlanner())
Explicit pass

An agent with nothing to add returns an empty reply, recorded as a turn that advances the planner without adding content.


Best Practices

Keep custom planners pure

No clock, no RNG, no I/O. Purity is the whole property that makes a room restart-safe.

Roster order is your tie-breaker

When multiple agents are admitted (round 0, or several @-mentions in one round), they speak in roster order, not mention order. Design roster order intentionally.

Set caps conservatively

max_rounds and max_messages are the guardrails against runaway loops. RoomConfig() defaults to 3 rounds and 20 messages per activity.

Persist the transcript for durability

Room keeps the transcript in memory only. To survive a restart, persist room.transcript in your wrapper and rebuild the room from it — the pure planner resumes the identical schedule.

Don't rely on lifetime transcript length

Message caps are per activity (per human message), by design. A long historical transcript never permanently disables a room.


Make it Live

The core Room is in-memory and I/O-free by design. To run a room over a live platform (Telegram / Slack / Discord / webhook) with a durable transcript and restart-safe schedule, use GatewayRoom from praisonai_bot.bots — it wraps the same core Room, swaps the transcript in and out via BotSessionManager, and plugs in anywhere a single agent is bound today.

Gateway Rooms

Run a rostered room over a live platform with a durable, restart-safe transcript.


Related