feat: implement chatroom for multi-llm conversation#162
Conversation
…om framework - Implemented ChatRoom context manager for multi-agent perspective-aware message routing. - Added identity awareness, system prompt enrichment, and automatic roster injection. - Added support for private channels, visible_to restrictions, and interleaved histories. - Refactored game_werewolf_chatroom.py to dynamically scale up to 7 players. - Added unit tests verifying multi-directional privacy and sealed bid isolation.
…oting bugs - Fixed cache_id in runs.py and slug in serialization.py to resolve the actual model version identifier instead of participant name. - Fixed werewolf game vote extraction robustness in game_werewolf_chatroom.py to prevent false-positives on mentioned names. - Fixed test_corporate_takeover_chatroom assertion types in test_chatroom.py.
…fix streaming typeerrors - Switched game_werewolf_chatroom.py to use structured WerewolfVote outputs. - Enabled kbench.config.enable_interactive_mode() and player.stream_responses = True for live onstream rendering. - Added survival/existence validation to voting loops to prevent crashes during ties or votes on dead players. - Fixed panel.py new_chunk streaming TypeError by extracting string content from LLMResponse chunk objects. - Updated tests/test_chatroom.py werewolf mock responses to return structured JSON.
…update design doc - Remove 4-player backward compat; run_werewolf now strictly requires 7 players. - Upgrade Alice/Bob wolf prompts with double-bluff and distancing strategies. - Update test_werewolf_chatroom to simulate a full 2-round, 7-player game. - Add section 9.5 to design.md for Panel streaming bug fix. - Remove example-specific structured voting details from design doc.
…vatars - Replace fuzzy name matching with explicit eligible name lists in vote prompts. - Use neutral role-agnostic avatars to avoid spoiling werewolf identities.
bc7701a to
633ced9
Compare
ca3ee6b to
2e84931
Compare
e8e876e to
9b3ec0e
Compare
develra
left a comment
There was a problem hiding this comment.
Reviewed at a high level to the best of my ability in a time-boxed way (30 minutes) - mostly looking at the examples and tests. LGTM - neat feature, but def might have missed some more subtle issues.
| def __init__( | ||
| self, | ||
| *, | ||
| system_prompt: str | None = None, |
There was a problem hiding this comment.
This is probably the reason you was have to copy LLMChat instanses while adding them to a room. I find it confusing that we are adding system_prompt llm-wide attribute that is not used in prompt
There was a problem hiding this comment.
Yes! This is also related to #162 (comment), killed two birds
| system = room._build_system_prompt(self) | ||
| perspective = room._build_perspective(self) | ||
|
|
||
| response = self.respond( | ||
| system=system, schema=schema, input_messages=perspective, **kwargs | ||
| ) |
There was a problem hiding this comment.
I feel like it belongs to room as it uses its private methods. Maybe something like room.interact(llm). This way you can store system_prompt per participant in eg ChatRoom.system_prompts.
There was a problem hiding this comment.
This is great insight! I have refactored the design
- moved application of
system_promptto chatroom. - used
Participantas a simple wrapper class for just llm, so we don't need to distinguish and clone - moved them to a dedicated module
rooms.
| from kaggle_benchmarks._config import ExecutionMode, config | ||
| from kaggle_benchmarks.actors import Actor, LLMChat, system, user | ||
| from kaggle_benchmarks.chats import last_reasoning_traces | ||
| from kaggle_benchmarks.chats import ChatRoom, last_reasoning_traces |
There was a problem hiding this comment.
I would prefer forcing using it like kbench.chats.ChatRoom or even move it to a separate module kbench.rooms.ChatRoom
| from kaggle_benchmarks.chats import ChatRoom, last_reasoning_traces |
There was a problem hiding this comment.
Done. Keep it in chats module for now so it's simpler on user side, and also makes potential circular imports easier. If we have more different use cases, will move it to dedicated module.
Wait, I think your other comments on system_prompt should belong to the chatroom will makes this design simpler, and so we should move them to a dedicated module. Let me try this refactoring first.
ffcf4c1 to
1760c02
Compare
1760c02 to
7101247
Compare
89ced10 to
e0aeff2
Compare
ChatRoom — Multi-agent conversation primitive
What
ChatRoomis a shared conversation context forkaggle-benchmarksthat letsmultiple LLMs converse with full awareness of each other's identities and roles.
Key capabilities:
assistantand peers' messages as attributedusermessages, built fresheach turn from a single ground-truth log.
LLMChatinstancecan back multiple participants in multiple rooms without cloning or shared
mutable state. A lightweight
Participantwrapper owns all per-room identity.visible_torestrictions forone-shot directives, plus full
private_channelsubrooms for multi-turnprivate conversations that interleave chronologically with the public timeline.
room.post()sendsdirectives from a named narrator that LLMs are explicitly told (via the
roster) to treat as system instructions, not peer speech.
Why
Multi-agent evaluation (debate, negotiation, social deduction, cooperative games)
is an increasingly important dimension for frontier LLM benchmarking, but the
existing
ChatAPI doesn't support it natively:LLMs are unaware of each other. Each agent has an isolated chat context.
The user manually forwards messages between them, stripping and re-injecting
roles. LLMs have no idea they are talking to another LLM.
Boilerplate is high. Existing multi-agent benchmarks in this repo each
re-implement ~40–160 lines of manual orchestration.
No conversation memory. Some benchmarks create a brand new
Chatevery turn, leaving LLMs with zero memory of previous turns.
How
Core Abstraction
A
ChatRoomis a shared conversation space. Users register participants viaadd_participant(), then drive the conversation inside awith room:blockusing two primitives:
room.post(msg)participant.reply()Two Classes, One Responsibility Each
Participant— a lightweight identity wrapper around a (possibly shared)LLMChat. Holdsname,avatar, and per-roomsystem_prompt. Its soleinteraction method
reply()is a guard + delegate to the room.ChatRoom— owns the participant roster, ground-truth transcript,narrator, perspective projection, and all turn orchestration.
The split matters: the room owns all per-participant customizations, the
backing
LLMChatstays shared and stateless. The samellmobject can backmany participants in many rooms simultaneously without interference, because
identity lives on the
Participant, not on theLLMChat.Participant Registration (
add_participant)room.add_participant(llm, *, name=, avatar=, system_prompt=):Participantwrapper is created; the underlyingLLMChatis reused as-is. This is intentional — object identity (is) onthe
Participantis what_build_perspectiveuses to distinguish "mymessages" from "their messages", so the wrapper must be fresh per participant
but the engine underneath need not be.
LLMChatinstances are accepted; scripted/code-drivenpeers are explicitly not routed through here (use
room.post()forscripted narration). This was a deliberate refactor to remove the
"peer-and-narrator-at-once" roster ambiguity.
[Name]:prefix convention used in perspective projection.
Perspective Projection — the core mechanism
When
participant.reply()runs,ChatRoom._generate_reply()does exactly threethings:
roster + --- + room prompt + --- + personal prompt. Rebuilt from scratch every turn (no caching) so each turn reflectsthe current roster (after any
remove_participant).room.historyonce. For each message:item.sender is viewer→ wrap withrole="assistant", no prefix.role="user", content prefixed[Name]: ....ChatRoomitems (private channels) are recursively inlined formembers and skipped for non-members.
_meta["visible_to"]excluding the viewer are filtered out.respond()withsystem=...,input_messages=<perspective>, andsender=<participant>. The response is appended directly intoroom.historybyrespond()— no double-write, no orphan chats, no_metapatching.The original messages in
room.historyare never mutated; projection alwayscreates new objects via a cached pool of synthetic
Actorinstances (avoidsO(N) allocations per call).
The Roster — what the LLM is told (and not told)
The auto-generated roster injected at the top of every system prompt tells the
LLM:
"You are Alice.").LLM has the system-vs-peer rule in hand before it learns peer names.
system_promptis never exposed,which is the anti-leak property hidden-role games like Werewolf depend on.
[Name]:prefix convention, plus an explicit "do not prefix your ownreply" instruction (LLMs commonly mirror the format they see, which would
otherwise produce double-prefixed messages).
[private: ChannelName]:convention and the rule that a privatedirective should be answered alone, not bundled with a public reply.
Private Information
Two mechanisms with different weights:
room.post(msg, visible_to=[...])— single-message audience filter.Lightweight; right for one-shot directives (e.g. handing each player a
secret role at game start). The message lives in the parent room's history
with a
_meta["visible_to"]tag.room.private_channel([alice, bob], name="Wolf Night")— a childChatRoom(full reuse, no special class). Used for multi-turn privateconversations. The child is registered into the parent's history lazily on
first entry; child messages are interleaved chronologically into members'
perspectives and invisible to non-members. Validations enforce that channel
participants are parent-room members, that channel names don't collide with
the parent or with siblings, and that the participant list has no duplicates.
Hard-Delete Removal
remove_participant()is a hard delete — the participant disappears frompeers' rosters next turn,
.reply()from a removedParticipantraisesRuntimeError, and historical messages stay attributed to them. Removal doesnot cascade into private channels by design (one knob per call).
Integration with Existing Framework
with room:integrates with the existingcontexts.enter()system, sochats.get_current_chat()returns the activeroom. A small
_cm_stacksupports reentrantwithblocks (e.g. the sameprivate channel re-entered each loop iteration).
ChatRoomlazily registers it into theparent chat's history the first time, so the full transcript renders in the
Panel UI without UI changes.
reply(schema=...)works for typed responses (e.g.game moves, votes). Peers see the response stringified via
str(content);override
__str__on your schema if you need to hide a private field._generate_replypassessender=<participant>into
respond()so the responseMessagecarries the right identity fromconstruction. Subscribers (UI, loggers) observe the correct sender on the
very first
new_messageevent, not as an after-the-fact patch.Example: Before & After
Tic-Tac-Toe
Before — fresh chat each turn, zero memory:
After — full history, attributed turns, narrator-driven game state:
Game state is broadcast by the room's narrator (
room.post) instead of by aseparate scripted
Actor, matching the current "narrator owns scriptedcontent, participants own LLM content" split.