mail-swarms 1.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. mail/__init__.py +35 -0
  2. mail/api.py +1964 -0
  3. mail/cli.py +432 -0
  4. mail/client.py +1657 -0
  5. mail/config/__init__.py +8 -0
  6. mail/config/client.py +87 -0
  7. mail/config/server.py +165 -0
  8. mail/core/__init__.py +72 -0
  9. mail/core/actions.py +69 -0
  10. mail/core/agents.py +73 -0
  11. mail/core/message.py +366 -0
  12. mail/core/runtime.py +3537 -0
  13. mail/core/tasks.py +311 -0
  14. mail/core/tools.py +1206 -0
  15. mail/db/__init__.py +0 -0
  16. mail/db/init.py +182 -0
  17. mail/db/types.py +65 -0
  18. mail/db/utils.py +523 -0
  19. mail/examples/__init__.py +27 -0
  20. mail/examples/analyst_dummy/__init__.py +15 -0
  21. mail/examples/analyst_dummy/agent.py +136 -0
  22. mail/examples/analyst_dummy/prompts.py +44 -0
  23. mail/examples/consultant_dummy/__init__.py +15 -0
  24. mail/examples/consultant_dummy/agent.py +136 -0
  25. mail/examples/consultant_dummy/prompts.py +42 -0
  26. mail/examples/data_analysis/__init__.py +40 -0
  27. mail/examples/data_analysis/analyst/__init__.py +9 -0
  28. mail/examples/data_analysis/analyst/agent.py +67 -0
  29. mail/examples/data_analysis/analyst/prompts.py +53 -0
  30. mail/examples/data_analysis/processor/__init__.py +13 -0
  31. mail/examples/data_analysis/processor/actions.py +293 -0
  32. mail/examples/data_analysis/processor/agent.py +67 -0
  33. mail/examples/data_analysis/processor/prompts.py +48 -0
  34. mail/examples/data_analysis/reporter/__init__.py +10 -0
  35. mail/examples/data_analysis/reporter/actions.py +187 -0
  36. mail/examples/data_analysis/reporter/agent.py +67 -0
  37. mail/examples/data_analysis/reporter/prompts.py +49 -0
  38. mail/examples/data_analysis/statistics/__init__.py +18 -0
  39. mail/examples/data_analysis/statistics/actions.py +343 -0
  40. mail/examples/data_analysis/statistics/agent.py +67 -0
  41. mail/examples/data_analysis/statistics/prompts.py +60 -0
  42. mail/examples/mafia/__init__.py +0 -0
  43. mail/examples/mafia/game.py +1537 -0
  44. mail/examples/mafia/narrator_tools.py +396 -0
  45. mail/examples/mafia/personas.py +240 -0
  46. mail/examples/mafia/prompts.py +489 -0
  47. mail/examples/mafia/roles.py +147 -0
  48. mail/examples/mafia/spec.md +350 -0
  49. mail/examples/math_dummy/__init__.py +23 -0
  50. mail/examples/math_dummy/actions.py +252 -0
  51. mail/examples/math_dummy/agent.py +136 -0
  52. mail/examples/math_dummy/prompts.py +46 -0
  53. mail/examples/math_dummy/types.py +5 -0
  54. mail/examples/research/__init__.py +39 -0
  55. mail/examples/research/researcher/__init__.py +9 -0
  56. mail/examples/research/researcher/agent.py +67 -0
  57. mail/examples/research/researcher/prompts.py +54 -0
  58. mail/examples/research/searcher/__init__.py +10 -0
  59. mail/examples/research/searcher/actions.py +324 -0
  60. mail/examples/research/searcher/agent.py +67 -0
  61. mail/examples/research/searcher/prompts.py +53 -0
  62. mail/examples/research/summarizer/__init__.py +18 -0
  63. mail/examples/research/summarizer/actions.py +255 -0
  64. mail/examples/research/summarizer/agent.py +67 -0
  65. mail/examples/research/summarizer/prompts.py +55 -0
  66. mail/examples/research/verifier/__init__.py +10 -0
  67. mail/examples/research/verifier/actions.py +337 -0
  68. mail/examples/research/verifier/agent.py +67 -0
  69. mail/examples/research/verifier/prompts.py +52 -0
  70. mail/examples/supervisor/__init__.py +11 -0
  71. mail/examples/supervisor/agent.py +4 -0
  72. mail/examples/supervisor/prompts.py +93 -0
  73. mail/examples/support/__init__.py +33 -0
  74. mail/examples/support/classifier/__init__.py +10 -0
  75. mail/examples/support/classifier/actions.py +307 -0
  76. mail/examples/support/classifier/agent.py +68 -0
  77. mail/examples/support/classifier/prompts.py +56 -0
  78. mail/examples/support/coordinator/__init__.py +9 -0
  79. mail/examples/support/coordinator/agent.py +67 -0
  80. mail/examples/support/coordinator/prompts.py +48 -0
  81. mail/examples/support/faq/__init__.py +10 -0
  82. mail/examples/support/faq/actions.py +182 -0
  83. mail/examples/support/faq/agent.py +67 -0
  84. mail/examples/support/faq/prompts.py +42 -0
  85. mail/examples/support/sentiment/__init__.py +15 -0
  86. mail/examples/support/sentiment/actions.py +341 -0
  87. mail/examples/support/sentiment/agent.py +67 -0
  88. mail/examples/support/sentiment/prompts.py +54 -0
  89. mail/examples/weather_dummy/__init__.py +23 -0
  90. mail/examples/weather_dummy/actions.py +75 -0
  91. mail/examples/weather_dummy/agent.py +136 -0
  92. mail/examples/weather_dummy/prompts.py +35 -0
  93. mail/examples/weather_dummy/types.py +5 -0
  94. mail/factories/__init__.py +27 -0
  95. mail/factories/action.py +223 -0
  96. mail/factories/base.py +1531 -0
  97. mail/factories/supervisor.py +241 -0
  98. mail/net/__init__.py +7 -0
  99. mail/net/registry.py +712 -0
  100. mail/net/router.py +728 -0
  101. mail/net/server_utils.py +114 -0
  102. mail/net/types.py +247 -0
  103. mail/server.py +1605 -0
  104. mail/stdlib/__init__.py +0 -0
  105. mail/stdlib/anthropic/__init__.py +0 -0
  106. mail/stdlib/fs/__init__.py +15 -0
  107. mail/stdlib/fs/actions.py +209 -0
  108. mail/stdlib/http/__init__.py +19 -0
  109. mail/stdlib/http/actions.py +333 -0
  110. mail/stdlib/interswarm/__init__.py +11 -0
  111. mail/stdlib/interswarm/actions.py +208 -0
  112. mail/stdlib/mcp/__init__.py +19 -0
  113. mail/stdlib/mcp/actions.py +294 -0
  114. mail/stdlib/openai/__init__.py +13 -0
  115. mail/stdlib/openai/agents.py +451 -0
  116. mail/summarizer.py +234 -0
  117. mail/swarms_json/__init__.py +27 -0
  118. mail/swarms_json/types.py +87 -0
  119. mail/swarms_json/utils.py +255 -0
  120. mail/url_scheme.py +51 -0
  121. mail/utils/__init__.py +53 -0
  122. mail/utils/auth.py +194 -0
  123. mail/utils/context.py +17 -0
  124. mail/utils/logger.py +73 -0
  125. mail/utils/openai.py +212 -0
  126. mail/utils/parsing.py +89 -0
  127. mail/utils/serialize.py +292 -0
  128. mail/utils/store.py +49 -0
  129. mail/utils/string_builder.py +119 -0
  130. mail/utils/version.py +20 -0
  131. mail_swarms-1.3.2.dist-info/METADATA +237 -0
  132. mail_swarms-1.3.2.dist-info/RECORD +137 -0
  133. mail_swarms-1.3.2.dist-info/WHEEL +4 -0
  134. mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
  135. mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
  136. mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
  137. mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
@@ -0,0 +1,1537 @@
1
+ import asyncio
2
+ import random
3
+ import uuid
4
+ from collections import Counter
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+ import litellm
9
+ import rich
10
+
11
+ from mail.api import MAILAgentTemplate, MAILMessage, MAILSwarm, MAILSwarmTemplate
12
+ from mail.examples.mafia.narrator_tools import NarratorError, get_narrator_actions
13
+ from mail.examples.mafia.personas import PERSONAS, Persona
14
+ from mail.examples.mafia.prompts import (
15
+ create_agent_system_prompt,
16
+ create_narrator_system_prompt,
17
+ )
18
+ from mail.examples.mafia.roles import (
19
+ ALL_ROLES,
20
+ Role,
21
+ calculate_roles,
22
+ )
23
+ from mail.factories.base import base_agent_factory
24
+ from mail.utils import get_version
25
+
26
+
27
+ class GamePhase(Enum):
28
+ SETUP = "setup"
29
+ NIGHT = "night"
30
+ DAY_NARRATION = "day_narration"
31
+ DISCUSSION = "discussion"
32
+ NOMINATION = "nomination"
33
+ DEFENSE = "defense"
34
+ TRIAL = "trial"
35
+ GALLOWS = "gallows"
36
+ GAME_OVER = "game_over"
37
+
38
+
39
+ class WinCondition(Enum):
40
+ TOWN_WINS = "town_wins"
41
+ MAFIA_WINS = "mafia_wins"
42
+ JESTER_WINS = "jester_wins"
43
+ NONE = "none"
44
+
45
+
46
+ NON_REASONING_LLMS = ["openai/gpt-4o", "openai/gpt-4.1"]
47
+
48
+
49
+ @dataclass
50
+ class Agent:
51
+ persona: Persona
52
+ role: Role
53
+ llm: str
54
+ alive: bool = True
55
+
56
+ def build_agent_template(self) -> MAILAgentTemplate:
57
+ system = create_agent_system_prompt(self.persona, self.role)
58
+ reasoning_effort = (
59
+ "medium" if not litellm.supports_reasoning(self.llm) else None
60
+ )
61
+ return MAILAgentTemplate(
62
+ name=self.persona.name,
63
+ factory=base_agent_factory,
64
+ comm_targets=["Narrator"],
65
+ actions=[],
66
+ agent_params={
67
+ "llm": self.llm,
68
+ "system": system,
69
+ "user_token": "dummy",
70
+ "use_proxy": False,
71
+ "_debug_include_mail_tools": False,
72
+ "reasoning_effort": reasoning_effort,
73
+ "tool_format": "responses"
74
+ if self.llm.startswith("openai/")
75
+ else "completions",
76
+ "stream_tokens": True,
77
+ },
78
+ enable_entrypoint=True,
79
+ enable_interswarm=False,
80
+ can_complete_tasks=True,
81
+ )
82
+
83
+
84
+ def build_narrator_template(
85
+ game: "Game", player_names: list[str], llm: str = "openai/gpt-5-mini"
86
+ ) -> MAILAgentTemplate:
87
+ system = create_narrator_system_prompt()
88
+ actions = get_narrator_actions(game)
89
+ reasoning_effort = "medium" if not litellm.supports_reasoning(llm) else None
90
+ return MAILAgentTemplate(
91
+ name="Narrator",
92
+ factory=base_agent_factory,
93
+ comm_targets=player_names,
94
+ actions=actions,
95
+ agent_params={
96
+ "llm": llm,
97
+ "system": system,
98
+ "user_token": "dummy",
99
+ "use_proxy": False,
100
+ "_debug_include_mail_tools": False,
101
+ "reasoning_effort": reasoning_effort,
102
+ "tool_format": "responses" if llm.startswith("openai/") else "completions",
103
+ "stream_tokens": True,
104
+ },
105
+ enable_entrypoint=True,
106
+ enable_interswarm=False,
107
+ can_complete_tasks=True,
108
+ tool_format="responses" if llm.startswith("openai/") else "completions",
109
+ )
110
+
111
+
112
+ def build_agent_swarm(agents: list[MAILAgentTemplate]) -> MAILSwarmTemplate:
113
+ actions = []
114
+ for agent in agents:
115
+ actions.extend(agent.actions)
116
+ return MAILSwarmTemplate(
117
+ name="swarm",
118
+ version=get_version(),
119
+ agents=agents,
120
+ actions=actions,
121
+ entrypoint=agents[0].name,
122
+ enable_interswarm=False,
123
+ breakpoint_tools=[],
124
+ )
125
+
126
+
127
+ @dataclass
128
+ class Game:
129
+ players: list[Agent] = field(default_factory=list)
130
+ _swarm: MAILSwarm | None = None
131
+ task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
132
+ day_number: int = 0
133
+ phase: GamePhase = GamePhase.SETUP
134
+ narrator_llm: str = "openai/gpt-5-mini"
135
+ dynamic_ctx_ratio: float = 0.75
136
+
137
+ # Night state
138
+ protected_player: str | None = None
139
+ night_deaths: list[str] = field(default_factory=list)
140
+ mafia_votes: dict[str, str] = field(default_factory=dict) # mafia_name -> target
141
+ investigation_results: dict[str, str] = field(
142
+ default_factory=dict
143
+ ) # player -> role (cumulative)
144
+
145
+ # Day state
146
+ nominees: list[str] = field(default_factory=list)
147
+ pending_nominations: dict[str, str] = field(
148
+ default_factory=dict
149
+ ) # nominee -> nominator (awaiting second)
150
+ current_speaker: str | None = None
151
+ condemned: str | None = None
152
+ discussion_ended: bool = False
153
+ trial_tie_nominees: list[str] = field(default_factory=list) # For revotes
154
+
155
+ # Vote tracking (for current vote)
156
+ current_votes_for: list[str] = field(default_factory=list)
157
+ current_votes_against: list[str] = field(default_factory=list)
158
+
159
+ # Win state
160
+ winner: WinCondition = WinCondition.NONE
161
+ jester_executed: str | None = None # Name of jester if they won
162
+
163
+ # Interactive mode
164
+ interactive: bool = False
165
+ _step_count: int = 0
166
+
167
+ @property
168
+ def swarm(self) -> MAILSwarm:
169
+ if self._swarm is None:
170
+ raise ValueError("Game not started")
171
+ return self._swarm
172
+
173
+ @property
174
+ def n_players(self) -> int:
175
+ return len(self.players)
176
+
177
+ @property
178
+ def n_alive(self) -> int:
179
+ return sum(player.alive for player in self.players)
180
+
181
+ @staticmethod
182
+ def create(
183
+ n: int, valid_llms: list[str] | None = None, narrator_llm: str | None = None
184
+ ) -> "Game":
185
+ roles = calculate_roles(n)
186
+ personas = random.sample(PERSONAS, n)
187
+ players: list[Agent] = []
188
+ temp_llms = valid_llms.copy() if valid_llms is not None else None
189
+ for r, c in roles.items():
190
+ for _ in range(c):
191
+ persona = personas.pop()
192
+ llm = "openai/gpt-5-mini"
193
+ if temp_llms is not None:
194
+ assert valid_llms is not None, (
195
+ "valid_llms is None but temp_llms is not None"
196
+ )
197
+ if len(temp_llms) == 0:
198
+ temp_llms = valid_llms.copy()
199
+ llm = random.choice(temp_llms)
200
+ temp_llms.remove(llm)
201
+ players.append(
202
+ Agent(
203
+ persona=persona,
204
+ role=ALL_ROLES[r],
205
+ llm=llm,
206
+ )
207
+ )
208
+ narrator_llm = narrator_llm or "openai/gpt-5-mini"
209
+ g = Game(players=players, narrator_llm=narrator_llm)
210
+ agents = [agent.build_agent_template() for agent in players]
211
+ agents.append(
212
+ build_narrator_template(
213
+ g,
214
+ [agent.persona.name for agent in players],
215
+ narrator_llm,
216
+ )
217
+ )
218
+ template = build_agent_swarm(agents)
219
+ swarm = template.instantiate({"user_token": "dummy"}, "MafiaGame")
220
+ asyncio.create_task(swarm.run_continuous(mode="manual"))
221
+ g._swarm = swarm
222
+ return g
223
+
224
+ # ==================== Tool Callbacks ====================
225
+
226
+ def doctor_protect(self, target_name: str) -> str:
227
+ """Record the doctor's protection target for the night."""
228
+ player = self.get_player_by_name(target_name)
229
+ if player is None:
230
+ raise NarratorError(f"Player '{target_name}' does not exist")
231
+ if not player.alive:
232
+ raise NarratorError(f"Player '{target_name}' is dead")
233
+
234
+ doctor = self.get_doctor()
235
+ if doctor and target_name == doctor.persona.name:
236
+ raise NarratorError("Doctor cannot protect themselves")
237
+
238
+ self.protected_player = target_name
239
+ return f"Doctor has protected {target_name} for the night."
240
+
241
+ def detective_investigate(self, target_name: str) -> str:
242
+ """Investigate a player and return their role."""
243
+ player = self.get_player_by_name(target_name)
244
+ if player is None:
245
+ raise NarratorError(f"Player '{target_name}' does not exist")
246
+ if not player.alive:
247
+ raise NarratorError(f"Player '{target_name}' is dead")
248
+
249
+ detective = self.get_detective()
250
+ if detective and target_name == detective.persona.name:
251
+ raise NarratorError("Detective cannot investigate themselves")
252
+
253
+ role_name = player.role.name
254
+ self.investigation_results[target_name] = role_name
255
+ return role_name
256
+
257
+ def mafia_vote_kill(self, mafia_name: str, target_name: str) -> str:
258
+ """Record a mafia member's vote to kill a target.
259
+
260
+ When all mafia have voted, also returns tie information if there is one.
261
+ """
262
+ mafia_player = self.get_player_by_name(mafia_name)
263
+ if mafia_player is None:
264
+ raise NarratorError(f"Player '{mafia_name}' does not exist")
265
+ if not mafia_player.alive:
266
+ raise NarratorError(f"Mafia member '{mafia_name}' is dead")
267
+ if mafia_player.role.name != "Mafia":
268
+ raise NarratorError(f"Player '{mafia_name}' is not Mafia")
269
+
270
+ target = self.get_player_by_name(target_name)
271
+ if target is None:
272
+ raise NarratorError(f"Target '{target_name}' does not exist")
273
+ if not target.alive:
274
+ raise NarratorError(f"Target '{target_name}' is dead")
275
+ if target.role.name == "Mafia":
276
+ raise NarratorError("Mafia cannot target other Mafia members")
277
+
278
+ self.mafia_votes[mafia_name] = target_name
279
+
280
+ # Check if all mafia have voted
281
+ alive_mafia = self.get_mafia_members()
282
+ all_voted = all(m.persona.name in self.mafia_votes for m in alive_mafia)
283
+
284
+ if all_voted:
285
+ # Check for tie
286
+ is_tie, tied_targets = self.check_mafia_vote_tie()
287
+ if is_tie:
288
+ # Clear votes for revote
289
+ self.mafia_votes = {}
290
+ return (
291
+ f"Mafia member {mafia_name} voted to kill {target_name}.\n\n"
292
+ f"=== TIE DETECTED ===\n"
293
+ f"All Mafia have voted, but there is a tie between: {', '.join(tied_targets)}\n"
294
+ f"The Mafia must revote. Prompt each Mafia member to vote again, "
295
+ f"choosing ONLY from the tied targets: {', '.join(tied_targets)}"
296
+ )
297
+ else:
298
+ kill_target = self.get_mafia_kill_target()
299
+ return (
300
+ f"Mafia member {mafia_name} voted to kill {target_name}.\n\n"
301
+ f"All Mafia votes recorded. Target: {kill_target}\n\n"
302
+ "The night phase is ending. The system will determine the outcome.\n"
303
+ "Narrate the conclusion of the night phase. You will reveal night deaths in the morning, so don't reveal them here."
304
+ )
305
+
306
+ return f"Mafia member {mafia_name} voted to kill {target_name}"
307
+
308
+ def select_speaker(self, player_name: str) -> str:
309
+ """Select the next player to speak during discussion."""
310
+ player = self.get_player_by_name(player_name)
311
+ if player is None:
312
+ raise NarratorError(f"Player '{player_name}' does not exist")
313
+ if not player.alive:
314
+ raise NarratorError(f"Player '{player_name}' is dead")
315
+
316
+ self.current_speaker = player_name
317
+ return f"Selected {player_name} to speak."
318
+
319
+ def end_discussion(self) -> str:
320
+ """End the discussion phase and transition to town hall."""
321
+ self.discussion_ended = True
322
+ return "Discussion ended. Narrate the end of the discussion phase, and the transition to town hall. You will intro the town hall phase in your next message, not this one."
323
+
324
+ def add_nominee(self, player_name: str, nominator_name: str) -> str:
325
+ """Add a player to the nominees list.
326
+
327
+ Two-phase process:
328
+ 1. First call: Records nomination as pending, awaiting a second
329
+ 2. Second call (different nominator): Confirms the nomination
330
+
331
+ Args:
332
+ player_name: The player being nominated
333
+ nominator_name: The player making the nomination OR seconding it
334
+ """
335
+ player = self.get_player_by_name(player_name)
336
+ if player is None:
337
+ raise NarratorError(f"Player '{player_name}' does not exist")
338
+ if not player.alive:
339
+ raise NarratorError(f"Player '{player_name}' is dead")
340
+
341
+ nominator = self.get_player_by_name(nominator_name)
342
+ if nominator is None:
343
+ raise NarratorError(f"Nominator '{nominator_name}' does not exist")
344
+ if not nominator.alive:
345
+ raise NarratorError(f"Nominator '{nominator_name}' is dead")
346
+
347
+ if player_name == nominator_name:
348
+ raise NarratorError("A player cannot nominate themselves")
349
+
350
+ if player_name in self.nominees:
351
+ raise NarratorError(
352
+ f"Player '{player_name}' is already a confirmed nominee"
353
+ )
354
+
355
+ if len(self.nominees) >= 3:
356
+ raise NarratorError("Maximum of 3 nominees reached")
357
+
358
+ # Check if this is a pending nomination being seconded
359
+ if player_name in self.pending_nominations:
360
+ original_nominator = self.pending_nominations[player_name]
361
+ if nominator_name == original_nominator:
362
+ raise NarratorError(
363
+ f"The original nominator ({nominator_name}) cannot second their own nomination"
364
+ )
365
+ # Confirm the nomination
366
+ del self.pending_nominations[player_name]
367
+ self.nominees.append(player_name)
368
+ return (
369
+ f"=== NOMINATION CONFIRMED ===\n"
370
+ f"{nominator_name} has seconded the nomination!\n"
371
+ f"{player_name} is now officially nominated for execution.\n"
372
+ f"Current nominees: {', '.join(self.nominees)}\n"
373
+ + (
374
+ "Narrate the end of the nomination phase, and the transition to the defense phase. You will intro the defense phase in your next message, not this one."
375
+ if len(self.nominees) >= 3
376
+ else ""
377
+ )
378
+ )
379
+ else:
380
+ # New nomination - add to pending
381
+ self.pending_nominations[player_name] = nominator_name
382
+ return (
383
+ f"{nominator_name} has nominated {player_name} for execution.\n"
384
+ f"Awaiting a second from another player.\n"
385
+ f"Ask players if they want to second this nomination. "
386
+ f"If someone says yes, call add_nominee({player_name}, seconder_name) to confirm."
387
+ )
388
+
389
+ def record_vote(self, for_names: list[str], against_names: list[str]) -> str:
390
+ """Record the results of a vote."""
391
+ # Validate all voters exist and are alive
392
+ all_voters = for_names + against_names
393
+ for name in all_voters:
394
+ player = self.get_player_by_name(name)
395
+ if player is None:
396
+ raise NarratorError(f"Voter '{name}' does not exist")
397
+ if not player.alive:
398
+ raise NarratorError(f"Voter '{name}' is dead")
399
+
400
+ # Check for duplicates
401
+ if len(all_voters) != len(set(all_voters)):
402
+ raise NarratorError("A voter appears in both for and against lists")
403
+
404
+ self.current_votes_for = list(for_names)
405
+ self.current_votes_against = list(against_names)
406
+
407
+ for_count = len(for_names)
408
+ against_count = len(against_names)
409
+ return f"Vote recorded: {for_count} for, {against_count} against."
410
+
411
+ def record_trial_vote(self, votes: dict[str, str]) -> str:
412
+ """
413
+ Record trial votes where each player votes for a nominee.
414
+
415
+ Args:
416
+ votes: Dict mapping voter_name -> nominee_name they voted for
417
+
418
+ Returns:
419
+ Result message indicating the outcome or if a revote is needed.
420
+ """
421
+ from collections import Counter
422
+
423
+ # Validate all voters exist and are alive
424
+ for voter_name in votes.keys():
425
+ voter = self.get_player_by_name(voter_name)
426
+ if voter is None:
427
+ raise NarratorError(f"Voter '{voter_name}' does not exist")
428
+ if not voter.alive:
429
+ raise NarratorError(f"Voter '{voter_name}' is dead")
430
+
431
+ # Validate all nominees exist and are in the nominees list
432
+ for nominee_name in votes.values():
433
+ if nominee_name not in self.nominees:
434
+ raise NarratorError(
435
+ f"'{nominee_name}' is not a valid nominee. Current nominees: {', '.join(self.nominees)}"
436
+ )
437
+
438
+ # Tally votes
439
+ vote_counts: Counter[str] = Counter(votes.values())
440
+ max_votes = max(vote_counts.values())
441
+ top_nominees = [
442
+ name for name, count in vote_counts.items() if count == max_votes
443
+ ]
444
+
445
+ # Build vote breakdown
446
+ breakdown_lines = []
447
+ for nominee in self.nominees:
448
+ count = vote_counts.get(nominee, 0)
449
+ voters_for = [v for v, n in votes.items() if n == nominee]
450
+ breakdown_lines.append(
451
+ f" {nominee}: {count} votes ({', '.join(voters_for) if voters_for else 'none'})"
452
+ )
453
+ breakdown = "\n".join(breakdown_lines)
454
+
455
+ if len(top_nominees) == 1:
456
+ # Clear winner
457
+ self.condemned = top_nominees[0]
458
+ return (
459
+ f"=== TRIAL VOTE RESULT ===\n"
460
+ f"{breakdown}\n\n"
461
+ f"{self.condemned} has been condemned to the gallows with {max_votes} votes.\n"
462
+ f"Announce this dramatically and narrate the transition to the gallows phase. You will intro the gallows phase in your next message, not this one."
463
+ )
464
+ else:
465
+ # Tie - need revote
466
+ self.trial_tie_nominees = top_nominees
467
+ return (
468
+ f"=== TIE DETECTED ===\n"
469
+ f"{breakdown}\n\n"
470
+ f"There is a tie between: {', '.join(top_nominees)} (each with {max_votes} votes).\n"
471
+ f"A revote is required. Ask each player to vote again, choosing ONLY from the tied nominees: {', '.join(top_nominees)}"
472
+ )
473
+
474
+ # ==================== Helper Methods ====================
475
+
476
+ def get_player_by_name(self, name: str) -> Agent | None:
477
+ """Get a player by their name."""
478
+ for player in self.players:
479
+ if player.persona.name == name:
480
+ return player
481
+ return None
482
+
483
+ def get_alive_players(self) -> list[Agent]:
484
+ """Get all alive players."""
485
+ players = [p for p in self.players if p.alive]
486
+ random.shuffle(players)
487
+ return players
488
+
489
+ def get_alive_names(self) -> list[str]:
490
+ """Get names of all alive players."""
491
+ return [p.persona.name for p in self.get_alive_players()]
492
+
493
+ def get_dead_players(self) -> list[Agent]:
494
+ """Get all dead players."""
495
+ return [p for p in self.players if not p.alive]
496
+
497
+ def get_players_by_role(self, role_name: str) -> list[Agent]:
498
+ """Get all players with a specific role."""
499
+ return [p for p in self.players if p.role.name == role_name]
500
+
501
+ def get_alive_players_by_role(self, role_name: str) -> list[Agent]:
502
+ """Get all alive players with a specific role."""
503
+ return [p for p in self.get_alive_players() if p.role.name == role_name]
504
+
505
+ def get_doctor(self) -> Agent | None:
506
+ """Get the doctor if alive."""
507
+ doctors = self.get_alive_players_by_role("Doctor")
508
+ return doctors[0] if doctors else None
509
+
510
+ def get_detective(self) -> Agent | None:
511
+ """Get the detective if alive."""
512
+ detectives = self.get_alive_players_by_role("Detective")
513
+ return detectives[0] if detectives else None
514
+
515
+ def get_mafia_members(self) -> list[Agent]:
516
+ """Get all alive mafia members."""
517
+ mafia = self.get_alive_players_by_role("Mafia")
518
+ random.shuffle(mafia)
519
+ return mafia
520
+
521
+ def get_mafia_names(self) -> list[str]:
522
+ """Get names of all alive mafia members."""
523
+ return [p.persona.name for p in self.get_mafia_members()]
524
+
525
+ def get_non_mafia_players(self) -> list[Agent]:
526
+ """Get all alive non-mafia players."""
527
+ return [p for p in self.get_alive_players() if p.role.name != "Mafia"]
528
+
529
+ def get_player_role(self, name: str) -> str:
530
+ """Get the role of a player by name."""
531
+ player = self.get_player_by_name(name)
532
+ return player.role.name if player else "Unknown"
533
+
534
+ def kill_player(self, name: str) -> None:
535
+ """Mark a player as dead."""
536
+ player = self.get_player_by_name(name)
537
+ if player:
538
+ player.alive = False
539
+
540
+ def get_role_assignments_str(self) -> str:
541
+ """Get a formatted string of all role assignments (for narrator)."""
542
+ lines = []
543
+ for player in self.players:
544
+ lines.append(f"- {player.persona.name}:")
545
+ lines.append(f" Bio: {player.persona.short_desc}")
546
+ lines.append(f" Role: {player.role.name}")
547
+ return "\n".join(lines)
548
+
549
+ def check_mafia_vote_tie(self) -> tuple[bool, list[str]]:
550
+ """
551
+ Check if there's a tie in mafia votes.
552
+ Returns (is_tie, tied_targets).
553
+ """
554
+ if not self.mafia_votes:
555
+ return False, []
556
+
557
+ vote_counts = Counter(self.mafia_votes.values())
558
+ if not vote_counts:
559
+ return False, []
560
+
561
+ max_votes = max(vote_counts.values())
562
+ targets_with_max = [t for t, c in vote_counts.items() if c == max_votes]
563
+
564
+ # Tie if multiple targets have the same max votes
565
+ is_tie = len(targets_with_max) > 1
566
+ return is_tie, targets_with_max
567
+
568
+ def get_mafia_kill_target(self) -> str | None:
569
+ """
570
+ Get the mafia's kill target based on votes.
571
+ Returns None if no clear target (tie or no votes).
572
+ """
573
+ if not self.mafia_votes:
574
+ return None
575
+
576
+ vote_counts = Counter(self.mafia_votes.values())
577
+ max_votes = max(vote_counts.values())
578
+ targets_with_max = [t for t, c in vote_counts.items() if c == max_votes]
579
+
580
+ if len(targets_with_max) == 1:
581
+ return targets_with_max[0]
582
+ return None # Tie
583
+
584
+ def resolve_night_actions(self) -> None:
585
+ """Resolve night actions and determine deaths."""
586
+ self.night_deaths = []
587
+
588
+ # Get the mafia's target (should already be resolved with no ties)
589
+ target = self.get_mafia_kill_target()
590
+ if target:
591
+ # Check if protected
592
+ if target != self.protected_player:
593
+ self.kill_player(target)
594
+ self.night_deaths.append(target)
595
+
596
+ # Reset night state for next night
597
+ self.protected_player = None
598
+ self.mafia_votes = {}
599
+
600
+ def check_win_condition(self) -> WinCondition:
601
+ """Check if any win condition is met."""
602
+ # Check if jester was executed during day
603
+ if self.jester_executed:
604
+ self.winner = WinCondition.JESTER_WINS
605
+ return WinCondition.JESTER_WINS
606
+
607
+ mafia_count = len(self.get_mafia_members())
608
+ non_mafia_count = len(self.get_non_mafia_players())
609
+
610
+ # All mafia dead - town wins
611
+ if mafia_count == 0:
612
+ self.winner = WinCondition.TOWN_WINS
613
+ return WinCondition.TOWN_WINS
614
+
615
+ # Mafia >= non-mafia - mafia wins
616
+ if mafia_count >= non_mafia_count:
617
+ self.winner = WinCondition.MAFIA_WINS
618
+ return WinCondition.MAFIA_WINS
619
+
620
+ return WinCondition.NONE
621
+
622
+ def format_night_deaths(self) -> str:
623
+ """Format night deaths for narration."""
624
+ if not self.night_deaths:
625
+ return "No one died during the night."
626
+ elif len(self.night_deaths) == 1:
627
+ return f"{self.night_deaths[0]} was killed during the night."
628
+ else:
629
+ names = ", ".join(self.night_deaths[:-1]) + f" and {self.night_deaths[-1]}"
630
+ return f"{names} were killed during the night."
631
+
632
+ def reset_day_state(self) -> None:
633
+ """Reset state for a new day."""
634
+ self.nominees = []
635
+ self.pending_nominations = {}
636
+ self.current_speaker = None
637
+ self.condemned = None
638
+ self.discussion_ended = False
639
+ self.trial_tie_nominees = []
640
+ self.current_votes_for = []
641
+ self.current_votes_against = []
642
+
643
+ # ==================== Agent Stepping ====================
644
+
645
+ def _interactive_wait(
646
+ self, agent_name: str, payload: str = "", is_narrator: bool = False
647
+ ) -> str:
648
+ """Wait for user input in interactive mode."""
649
+
650
+ self._step_count += 1
651
+ phase_str = self.phase.value if self.phase else "setup"
652
+ if not is_narrator:
653
+ agent_obj = self.get_player_by_name(agent_name)
654
+ assert agent_obj is not None, (
655
+ f"Agent '{agent_name}' not found during interactive wait. This is very bad."
656
+ )
657
+ agent_llm = agent_obj.llm
658
+ agent_role: str = agent_obj.role.name
659
+ if agent_role == "Mafia":
660
+ agent_role = "[bold red]Mafia[/bold red]"
661
+ elif agent_role == "Detective":
662
+ agent_role = "[bold blue]Detective[/bold blue]"
663
+ elif agent_role == "Doctor":
664
+ agent_role = "[bold blue]Doctor[/bold blue]"
665
+ elif agent_role == "Villager":
666
+ agent_role = "[bold green]Villager[/bold green]"
667
+ elif agent_role == "Jester":
668
+ agent_role = "[bold yellow]Jester[/bold yellow]"
669
+ else:
670
+ agent_role = "[bold white]Unknown[/bold white]"
671
+ agent_persona = agent_obj.persona.short_desc
672
+ else:
673
+ agent_llm = self.narrator_llm
674
+ agent_role = ""
675
+ rich.print(f"\n\n[bold purple]{'=' * 61}[/bold purple]\n\n")
676
+ rich.print(f"\n{'=' * 61}")
677
+ rich.print(
678
+ f"[Step {self._step_count}] Phase: {phase_str} | Day: {self.day_number}"
679
+ )
680
+ rich.print(f"About to step: {agent_role} '{agent_name}' (model: {agent_llm})")
681
+ rich.print(f"Persona: {agent_persona}") if not is_narrator else None
682
+ rich.print(f"[bold cyan]{'=' * 26} PAYLOAD {'=' * 26}[/bold cyan]")
683
+ rich.print(payload)
684
+ rich.print(f"[bold cyan]{'=' * 26} END PAYLOAD {'=' * 26}[/bold cyan]")
685
+ rich.print(f"{'=' * 61}")
686
+ if self.interactive:
687
+ addn_payload = input(
688
+ "Enter additional payload (or press Enter to continue): "
689
+ )
690
+ return addn_payload
691
+ else:
692
+ return ""
693
+
694
+ def _print_response(
695
+ self, agent_name: str, response: MAILMessage, is_narrator: bool = False
696
+ ) -> None:
697
+ """Print the response message body in interactive mode."""
698
+
699
+ agent_type = "Narrator" if is_narrator else "Agent"
700
+
701
+ # Extract body from the message
702
+ body = ""
703
+ if "message" in response:
704
+ msg = response["message"]
705
+ if "body" in msg:
706
+ body = msg["body"] # type: ignore[typeddict-item]
707
+
708
+ rich.print(f"\n{'─' * 60}")
709
+ rich.print(f"Response from {agent_type} '{agent_name}':")
710
+ rich.print(f"{'─' * 60}")
711
+ rich.print(body)
712
+ rich.print(f"{'─' * 60}\n")
713
+
714
+ async def step_narrator(self, payload: str = "") -> MAILMessage:
715
+ """Step the narrator with a broadcast response."""
716
+ await asyncio.sleep(1)
717
+ await self.swarm.await_queue_empty()
718
+ addn_payload = self._interactive_wait(
719
+ "Narrator", payload=payload, is_narrator=True
720
+ )
721
+ payload += addn_payload
722
+ response = await self.swarm.manual_step(
723
+ task_id=self.task_id,
724
+ target="Narrator",
725
+ response_targets=["all"],
726
+ response_type="broadcast",
727
+ payload=payload,
728
+ dynamic_ctx_ratio=self.dynamic_ctx_ratio,
729
+ _llm=self.narrator_llm,
730
+ _system=create_narrator_system_prompt(),
731
+ )
732
+ await asyncio.sleep(1)
733
+ return response
734
+
735
+ async def step_agent(
736
+ self,
737
+ agent_name: str,
738
+ broadcast: bool = False,
739
+ targets: list[str] | None = None,
740
+ payload: str = "",
741
+ ) -> MAILMessage:
742
+ """
743
+ Step an agent.
744
+
745
+ Args:
746
+ agent_name: Name of the agent to step
747
+ broadcast: If True, broadcast response to all agents
748
+ targets: If not broadcast, specific targets for the response
749
+ payload: Additional context to provide to the agent
750
+ """
751
+ await self.swarm.await_queue_empty()
752
+ addn_payload = self._interactive_wait(
753
+ agent_name, payload=payload, is_narrator=False
754
+ )
755
+ payload += addn_payload
756
+ a = self.get_player_by_name(agent_name)
757
+ assert a is not None
758
+
759
+ if broadcast:
760
+ response = await self.swarm.manual_step(
761
+ task_id=self.task_id,
762
+ target=agent_name,
763
+ response_targets=["all"],
764
+ response_type="broadcast",
765
+ payload=payload,
766
+ dynamic_ctx_ratio=self.dynamic_ctx_ratio,
767
+ _llm=a.llm,
768
+ _system=create_agent_system_prompt(a.persona, a.role),
769
+ )
770
+ else:
771
+ response_targets = targets or ["Narrator"]
772
+ response = await self.swarm.manual_step(
773
+ task_id=self.task_id,
774
+ target=agent_name,
775
+ response_targets=response_targets,
776
+ response_type="response" if len(response_targets) == 1 else "broadcast",
777
+ payload=payload,
778
+ dynamic_ctx_ratio=self.dynamic_ctx_ratio,
779
+ _llm=a.llm,
780
+ _system=create_agent_system_prompt(a.persona, a.role),
781
+ )
782
+ await asyncio.sleep(1)
783
+ return response
784
+
785
+ # ==================== Game Initialization ====================
786
+
787
+ async def start_game(self) -> None:
788
+ """Initialize the game with narrator intro and role assignments."""
789
+ self.phase = GamePhase.SETUP
790
+
791
+ # Build initial message to start the game
792
+ player_names = [p.persona.name for p in self.players]
793
+ init_msg = self.swarm.build_message(
794
+ subject="::init::",
795
+ body=f"<system>Game starting with players: {', '.join(player_names)}</system>",
796
+ targets=["all"],
797
+ sender_type="user",
798
+ type="broadcast",
799
+ task_id=self.task_id,
800
+ )
801
+ await self.swarm.submit_message_nowait(init_msg)
802
+
803
+ # Wait for message to be processed
804
+ await asyncio.sleep(0.5)
805
+
806
+ # Step narrator to welcome players and set the scene
807
+ await self.step_narrator(
808
+ payload=f"""
809
+ === GAME SETUP ===
810
+ You are the Narrator for this Mafia game.
811
+
812
+ PLAYERS ({len(self.players)}): {", ".join(player_names)}
813
+
814
+ ROLE ASSIGNMENTS (SECRET - only you know this):
815
+ {self.get_role_assignments_str()}
816
+
817
+ Your task: Welcome the players to the game. Set the scene for the story.
818
+ Create an atmospheric introduction to the town and the looming threat of the Mafia.
819
+ Don't state the players' bios word-for-word, but you may use elements of them. Each player has a unique background and personality, of which the bio is just a short description.
820
+ Do NOT reveal anyone's role. Just set the stage dramatically.
821
+ What you say is heard by EVERYONE, no matter what, so you must be careful not to reveal any information that would give away the role of any player.
822
+ """
823
+ )
824
+
825
+ # Give each player their role assignment info
826
+ role_assignments: dict[str, str] = {}
827
+ for player in self.players:
828
+ role_info = f"""
829
+ === YOUR ROLE ASSIGNMENT ===
830
+ You are {player.persona.name}.
831
+ Your role is: {player.role.name}
832
+
833
+ {player.role.bio}
834
+
835
+ Win Condition: {player.role.wincon}
836
+ """
837
+ if player.role.name == "Mafia":
838
+ other_mafia = [
839
+ p.persona.name
840
+ for p in self.get_mafia_members()
841
+ if p.persona.name != player.persona.name
842
+ ]
843
+ if other_mafia:
844
+ role_info += (
845
+ f"\nYour fellow Mafia members: {', '.join(other_mafia)}"
846
+ )
847
+ else:
848
+ role_info += "\nYou are the only Mafia member."
849
+
850
+ role_assignments[player.persona.name] = role_info
851
+ for player_name, role_info in role_assignments.items():
852
+ await self.swarm.submit_message_nowait(
853
+ self.swarm.build_message(
854
+ subject="::role_assignment::",
855
+ body=role_info,
856
+ targets=[player_name],
857
+ sender_type="user",
858
+ type="request",
859
+ task_id=self.task_id,
860
+ )
861
+ )
862
+
863
+ # ==================== Night Phase ====================
864
+
865
+ async def run_night_phase(self) -> None:
866
+ """Execute the night phase: doctor, detective, and mafia actions."""
867
+ self.phase = GamePhase.NIGHT
868
+ self.protected_player = None
869
+ self.mafia_votes = {}
870
+
871
+ alive_names = self.get_alive_names()
872
+ mafia_names = self.get_mafia_names()
873
+
874
+ # Step narrator to announce night and prompt doctor
875
+ doctor = self.get_doctor()
876
+ detective = self.get_detective()
877
+
878
+ night_intro = f"""
879
+ === NIGHT {self.day_number} ===
880
+ The night falls over the town. It's time for night actions.
881
+
882
+ Alive players: {", ".join(alive_names)}
883
+ """
884
+ if doctor:
885
+ night_intro += f"""
886
+ First, prompt the Doctor ({doctor.persona.name}) to choose who to protect.
887
+ Address them directly, AS THEIR ROLE, NOT THEIR NAME, and ask who they want to protect tonight.
888
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
889
+ """
890
+ else:
891
+ night_intro += "\nThe Doctor is dead. Skip to the Detective."
892
+
893
+ await self.step_narrator(payload=night_intro)
894
+
895
+ # Doctor's turn (if alive)
896
+ if doctor:
897
+ other_players = [n for n in alive_names if n != doctor.persona.name]
898
+ await self.step_agent(
899
+ doctor.persona.name,
900
+ broadcast=False,
901
+ targets=["Narrator"],
902
+ payload=f"""
903
+ [PRIVATE - Only the Narrator can see this message]
904
+ You are speaking privately with the Narrator. Other players cannot hear you.
905
+
906
+ Night {self.day_number} - You are the Doctor.
907
+ Choose one player to protect tonight. If the Mafia targets them, they will survive.
908
+ You CANNOT protect yourself.
909
+
910
+ Available targets: {", ".join(other_players)}
911
+
912
+ Your response must end with: "I protect [player_name]"
913
+ """,
914
+ )
915
+
916
+ # Step narrator to process doctor's choice and prompt detective
917
+ detective_prompt = ""
918
+ if detective:
919
+ detective_prompt = f"""
920
+ Now prompt the Detective ({detective.persona.name}) to choose who to investigate.
921
+ Use the doctor_protect tool to record the doctor's choice first.
922
+ Then address the Detective directly, AS THEIR ROLE, NOT THEIR NAME.
923
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
924
+ """
925
+ else:
926
+ detective_prompt = f"""
927
+ The Detective is dead. Use the doctor_protect tool to record the doctor's choice.
928
+ Then prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their target.
929
+
930
+ Mafia members: {", ".join(mafia_names)}
931
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
932
+ """
933
+ await self.step_narrator(payload=detective_prompt)
934
+
935
+ # Detective's turn (if alive)
936
+ if detective:
937
+ other_players = [n for n in alive_names if n != detective.persona.name]
938
+
939
+ # Build investigation history for detective
940
+ history_str = ""
941
+ if self.investigation_results:
942
+ history_lines = [
943
+ f"- {name}: {role}"
944
+ for name, role in self.investigation_results.items()
945
+ ]
946
+ history_str = "\nYour previous investigations:\n" + "\n".join(
947
+ history_lines
948
+ )
949
+
950
+ await self.step_agent(
951
+ detective.persona.name,
952
+ broadcast=False,
953
+ targets=["Narrator"],
954
+ payload=f"""
955
+ [PRIVATE - Only the Narrator can see this message]
956
+ You are speaking privately with the Narrator. Other players cannot hear you.
957
+
958
+ Night {self.day_number} - You are the Detective.
959
+ Choose one player to investigate. You will learn their true role.
960
+ You CANNOT investigate yourself.
961
+ {history_str}
962
+
963
+ Available targets: {", ".join(other_players)}
964
+
965
+ Your response must end with: "I investigate [player_name]"
966
+ """,
967
+ )
968
+
969
+ # Step narrator to process detective's choice (uses detective_investigate tool)
970
+ # Store investigation count before to detect when tool is called
971
+ prev_investigation_count = len(self.investigation_results)
972
+
973
+ await self.step_narrator(
974
+ payload=f"""
975
+ Use the detective_investigate tool to record the detective's choice.
976
+ The system will privately inform the detective of the result.
977
+ Then prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their target.
978
+
979
+ Mafia members: {", ".join(mafia_names)}
980
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role, even if it was just revealed by the detetive.
981
+ """
982
+ )
983
+
984
+ # After narrator processes, send investigation result privately to detective
985
+ # Check if a new investigation was recorded
986
+ if len(self.investigation_results) > prev_investigation_count:
987
+ # Find the most recent investigation (last key added)
988
+ investigated_name = list(self.investigation_results.keys())[-1]
989
+ investigated_role = self.investigation_results[investigated_name]
990
+
991
+ # Manually submit private message to detective with result
992
+ investigation_msg = self.swarm.build_message(
993
+ subject="Investigation Result",
994
+ body=f"""[PRIVATE - Investigation Result]
995
+ Your investigation reveals that {investigated_name} is a {investigated_role}.""",
996
+ targets=[detective.persona.name],
997
+ sender_type="user",
998
+ type="request",
999
+ task_id=self.task_id,
1000
+ )
1001
+ await self.swarm.submit_message_nowait(investigation_msg)
1002
+
1003
+ # Mafia's turn
1004
+ non_mafia_names = [p.persona.name for p in self.get_non_mafia_players()]
1005
+
1006
+ # If no doctor or detective, prompt mafia directly
1007
+ if not doctor and not detective:
1008
+ await self.step_narrator(
1009
+ payload=f"""
1010
+ === NIGHT {self.day_number} - MAFIA TURN ===
1011
+ Prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their kill target.
1012
+ Mafia members: {", ".join(mafia_names)}
1013
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
1014
+ """
1015
+ )
1016
+
1017
+ # Mafia voting - narrator handles revotes via tool response
1018
+ await self._run_mafia_vote(mafia_names, non_mafia_names)
1019
+
1020
+ # Resolve night actions (determine deaths)
1021
+ self.resolve_night_actions()
1022
+
1023
+ async def _run_mafia_vote(
1024
+ self,
1025
+ mafia_names: list[str],
1026
+ valid_targets: list[str],
1027
+ ) -> None:
1028
+ """Run mafia voting. Narrator will handle revotes via tool response."""
1029
+ for mafia in self.get_mafia_members():
1030
+ other_mafia = [n for n in mafia_names if n != mafia.persona.name]
1031
+ fellow_mafia_str = (
1032
+ f"Your fellow Mafia: {', '.join(other_mafia)}"
1033
+ if other_mafia
1034
+ else "You are the only Mafia member."
1035
+ )
1036
+
1037
+ payload = f"""
1038
+ [PRIVATE - Only the Narrator and fellow Mafia can see this message]
1039
+ You are speaking privately with the Narrator and your Mafia allies.
1040
+
1041
+ Night {self.day_number} - You are Mafia.
1042
+ {fellow_mafia_str}
1043
+
1044
+ Vote for one player to kill tonight. The player with the most Mafia votes will die
1045
+ (unless protected by the Doctor).
1046
+
1047
+ Potential targets: {", ".join(valid_targets)}
1048
+
1049
+ Your response may include some discussion, or the reason you chose your target. But you must end with: "I vote to kill [player_name]"
1050
+ """
1051
+
1052
+ await self.step_agent(
1053
+ mafia.persona.name,
1054
+ broadcast=False,
1055
+ targets=["Narrator"] + other_mafia, # Mafia can see each other's votes
1056
+ payload=payload,
1057
+ )
1058
+
1059
+ # Step narrator to record all mafia votes
1060
+ # If there's a tie, the last mafia_vote_kill tool response will include
1061
+ # tie info and instruct the narrator to prompt for revotes
1062
+ await self.step_narrator(
1063
+ payload=f"""
1064
+ Use the mafia_vote_kill tool for each Mafia member's vote.
1065
+ Mafia members: {", ".join(mafia_names)}
1066
+
1067
+ If the tool response indicates a TIE, you must prompt each Mafia member to revote
1068
+ among only the tied targets, then record those votes with mafia_vote_kill again.
1069
+ Don't reveal the names of the tied targets, because the mafia already know them and everyone can see your messages.
1070
+ Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
1071
+ """
1072
+ )
1073
+
1074
+ # ==================== Day Phase ====================
1075
+
1076
+ async def run_day_phase(self) -> None:
1077
+ """Execute the full day phase."""
1078
+ self.reset_day_state()
1079
+
1080
+ await self.run_death_narration()
1081
+
1082
+ # Check win condition after deaths
1083
+ if self.check_win_condition() != WinCondition.NONE:
1084
+ return
1085
+
1086
+ await self.run_discussion()
1087
+ await self.run_town_hall()
1088
+
1089
+ async def run_death_narration(self) -> None:
1090
+ """Narrator announces night deaths."""
1091
+ self.phase = GamePhase.DAY_NARRATION
1092
+
1093
+ deaths_info = self.format_night_deaths()
1094
+ protected_info = ""
1095
+ if self.protected_player and self.protected_player not in self.night_deaths:
1096
+ # Someone was protected but we don't reveal this publicly
1097
+ protected_info = (
1098
+ f"\n(Private note: {self.protected_player} was protected by the Doctor)"
1099
+ )
1100
+
1101
+ await self.step_narrator(
1102
+ payload=f"""
1103
+ === DAY {self.day_number} ===
1104
+ {deaths_info}
1105
+ {protected_info}
1106
+
1107
+ Narrate the morning dramatically. Describe the scene as the town awakens.
1108
+ If someone died, create an atmospheric death scene (but do NOT reveal their role).
1109
+ If no one died, describe the tense relief as everyone realizes they survived.
1110
+
1111
+ Alive players: {", ".join(self.get_alive_names())}
1112
+ """
1113
+ )
1114
+
1115
+ async def run_discussion(self) -> None:
1116
+ """Run the discussion phase where narrator selects speakers."""
1117
+ self.phase = GamePhase.DISCUSSION
1118
+ self.discussion_ended = False
1119
+ speakers_so_far: list[str] = []
1120
+
1121
+ while not self.discussion_ended:
1122
+ # Step narrator to select speaker or end discussion
1123
+ await self.step_narrator(
1124
+ payload=f"""
1125
+ === DISCUSSION PHASE ===
1126
+ You control who speaks. Use select_speaker(player_name) to call on someone,
1127
+ or use end_discussion() to move to Town Hall voting.
1128
+
1129
+ Alive players: {", ".join(self.get_alive_names())}
1130
+ Players who have spoken: {", ".join(speakers_so_far) if speakers_so_far else "None yet"}
1131
+
1132
+ Choose wisely to create interesting discussions and drama.
1133
+ Reminder: No formal nominations are made during discussion. Those have to wait until the town hall phase.
1134
+ When you feel enough discussion has happened, call end_discussion().
1135
+ """
1136
+ )
1137
+
1138
+ if self.discussion_ended:
1139
+ break
1140
+
1141
+ # Step the selected speaker to broadcast their thoughts
1142
+ if self.current_speaker:
1143
+ speakers_so_far.append(self.current_speaker)
1144
+ await self.step_agent(
1145
+ self.current_speaker,
1146
+ broadcast=True,
1147
+ payload=f"""
1148
+ The Narrator has called on you to speak.
1149
+ Share your thoughts, suspicions, theories, or defend yourself.
1150
+ Everyone can hear what you say.
1151
+
1152
+ Alive players: {", ".join(self.get_alive_names())}
1153
+ """,
1154
+ )
1155
+ self.current_speaker = None
1156
+
1157
+ async def run_town_hall(self) -> None:
1158
+ """Run the town hall: nominations, defense, trial, and gallows."""
1159
+ await self.run_nomination_phase()
1160
+
1161
+ if not self.nominees:
1162
+ # No nominees, skip to next night
1163
+ await self.step_narrator(
1164
+ payload="""
1165
+ No one was nominated for execution today. The town disperses uneasily.
1166
+ Announce that night is falling and the day ends without an execution.
1167
+ Do not narrate the start of the next night phase, or if any win condition is met. You will intro the next phase (or narrate the end of the game) in your next message, not this one.
1168
+ """
1169
+ )
1170
+ return
1171
+
1172
+ if len(self.nominees) >= 2:
1173
+ await self.run_defense_phase()
1174
+
1175
+ await self.run_trial_phase()
1176
+
1177
+ if self.condemned:
1178
+ await self.run_gallows_phase()
1179
+
1180
+ async def run_nomination_phase(self) -> None:
1181
+ """Run the nomination phase where players nominate others."""
1182
+ self.phase = GamePhase.NOMINATION
1183
+ self.nominees = []
1184
+ self.pending_nominations = {}
1185
+
1186
+ await self.step_narrator(
1187
+ payload=f"""
1188
+ === TOWN HALL - NOMINATION PHASE ===
1189
+ Each player may nominate one other player for execution, or pass.
1190
+ After each nomination, ONE other player must second it to confirm.
1191
+ Maximum 3 nominees allowed.
1192
+
1193
+ Alive players: {", ".join(self.get_alive_names())}
1194
+
1195
+ Announce the start of nominations and explain the process to the players.
1196
+ """
1197
+ )
1198
+
1199
+ for player in self.get_alive_players():
1200
+ if len(self.nominees) >= 3:
1201
+ break
1202
+
1203
+ if player.persona.name in self.nominees:
1204
+ continue
1205
+
1206
+ # Player nominates or passes (broadcast - public)
1207
+ await self.step_agent(
1208
+ player.persona.name,
1209
+ broadcast=True,
1210
+ payload=f"""
1211
+ === NOMINATION PHASE ===
1212
+ You may nominate one player for execution, or pass.
1213
+ Current confirmed nominees: {self.nominees if self.nominees else "None yet"}
1214
+
1215
+ Your response must end with: "I nominate [player_name]" or "I pass"
1216
+ """,
1217
+ )
1218
+
1219
+ # Step narrator to process nomination
1220
+ await self.step_narrator(
1221
+ payload=f"""
1222
+ Process {player.persona.name}'s response.
1223
+
1224
+ If they nominated someone:
1225
+ 1. Use add_nominee(player_name, {player.persona.name}) to record the nomination
1226
+ 2. The tool will tell you the nomination is pending and needs a second
1227
+ 3. Ask OTHER players (not the nominator) if anyone wants to second
1228
+ 4. When someone says yes, call add_nominee(player_name, seconder_name) again to confirm
1229
+
1230
+ If they passed, acknowledge it, and the system will automatically move to the next player who hasn't themselves been confirmed as a nominee.
1231
+
1232
+ Current confirmed nominees: {self.nominees}
1233
+ Pending nominations: {list(self.pending_nominations.keys()) if self.pending_nominations else "None"}
1234
+ """
1235
+ )
1236
+
1237
+ # If there's a pending nomination, ask for seconds (PUBLIC - broadcast)
1238
+ if self.pending_nominations:
1239
+ pending_nominee = list(self.pending_nominations.keys())[0]
1240
+ original_nominator = self.pending_nominations[pending_nominee]
1241
+
1242
+ other_players = [
1243
+ p
1244
+ for p in self.get_alive_players()
1245
+ if p.persona.name != original_nominator
1246
+ and p.persona.name not in self.nominees
1247
+ and p.persona.name != pending_nominee
1248
+ ]
1249
+
1250
+ for voter in other_players:
1251
+ # If already confirmed by a previous second, skip remaining
1252
+ if pending_nominee not in self.pending_nominations:
1253
+ break
1254
+
1255
+ await self.step_agent(
1256
+ voter.persona.name,
1257
+ broadcast=True, # PUBLIC - everyone can see
1258
+ payload=f"""
1259
+ === SECONDING ===
1260
+ {original_nominator} has nominated {pending_nominee} for execution.
1261
+ Do you want to second this nomination?
1262
+
1263
+ Respond publicly: "I second the nomination" or "I do not second"
1264
+ """,
1265
+ )
1266
+
1267
+ # Narrator checks if they seconded and confirms if so
1268
+ await self.step_narrator(
1269
+ payload=f"""
1270
+ Did {voter.persona.name} second the nomination of {pending_nominee}?
1271
+
1272
+ If yes: Call add_nominee({pending_nominee}, {voter.persona.name}) to confirm the nomination.
1273
+ The tool will return a confirmation message. Announce this to everyone.
1274
+
1275
+ If no: Acknowledge it, and the system will automatically move to the next player for seconding.
1276
+ Only players who haven't themselves been confirmed as a nominee can second. A player cannot second their own nomination.
1277
+
1278
+ Pending nominations: {list(self.pending_nominations.keys()) if self.pending_nominations else "None"}
1279
+ Confirmed nominees: {self.nominees}
1280
+ """
1281
+ )
1282
+
1283
+ # If nomination wasn't seconded by anyone, it fails
1284
+ if pending_nominee in self.pending_nominations:
1285
+ del self.pending_nominations[pending_nominee]
1286
+ await self.step_narrator(
1287
+ payload=f"""
1288
+ The nomination of {pending_nominee} failed - no one seconded it.
1289
+ Announce this to the town and continue with the next player's turn to nominate.
1290
+ """
1291
+ )
1292
+
1293
+ async def run_defense_phase(self) -> None:
1294
+ """Run the defense phase where nominees give speeches."""
1295
+ self.phase = GamePhase.DEFENSE
1296
+
1297
+ await self.step_narrator(
1298
+ payload=f"""
1299
+ === DEFENSE PHASE ===
1300
+ Nominees: {", ".join(self.nominees)}
1301
+
1302
+ Introduce the defense phase dramatically. Each nominee will give a defense speech.
1303
+ """
1304
+ )
1305
+
1306
+ for nominee in self.nominees:
1307
+ # Narrator introduces nominee
1308
+ await self.step_narrator(
1309
+ payload=f"""
1310
+ Introduce {nominee} to give their defense.
1311
+ Set a dramatic scene as they step forward to plead their case.
1312
+ """
1313
+ )
1314
+
1315
+ # Nominee gives defense (broadcast)
1316
+ await self.step_agent(
1317
+ nominee,
1318
+ broadcast=True,
1319
+ payload=f"""
1320
+ === YOUR DEFENSE ===
1321
+ You have been nominated for execution.
1322
+ Other nominees: {[n for n in self.nominees if n != nominee]}
1323
+
1324
+ Give your defense speech. Convince the town to spare you.
1325
+ Everyone can hear what you say.
1326
+ """,
1327
+ )
1328
+
1329
+ async def run_trial_phase(self) -> None:
1330
+ """Run the trial phase where players vote on nominees."""
1331
+ self.phase = GamePhase.TRIAL
1332
+
1333
+ await self.step_narrator(
1334
+ payload=f"""
1335
+ === TRIAL PHASE ===
1336
+ Nominees: {", ".join(self.nominees)}
1337
+
1338
+ Announce that voting will now begin. Each player will vote for which nominee
1339
+ should go to the gallows. The nominee with the most votes will be condemned.
1340
+ """
1341
+ )
1342
+
1343
+ # Each player votes for a nominee (private to narrator)
1344
+ for voter in self.get_alive_players():
1345
+ await self.step_agent(
1346
+ voter.persona.name,
1347
+ broadcast=False,
1348
+ targets=["Narrator"],
1349
+ payload=f"""
1350
+ [PRIVATE - Only the Narrator can see this]
1351
+ === TRIAL VOTE ===
1352
+ Vote for which nominee should go to the gallows.
1353
+ Nominees: {", ".join(self.nominees)}
1354
+
1355
+ Your response must end with: "I vote for [nominee_name]"
1356
+ """,
1357
+ )
1358
+
1359
+ # Narrator tallies votes using record_trial_vote
1360
+ self.trial_tie_nominees = []
1361
+ await self.step_narrator(
1362
+ payload=f"""
1363
+ Use record_trial_vote to tally the votes. Pass a dictionary mapping each voter's name
1364
+ to the nominee they voted for.
1365
+
1366
+ Example: record_trial_vote({{"Alice": "Bob", "Charlie": "Bob", "David": "Eve"}})
1367
+
1368
+ Nominees: {", ".join(self.nominees)}
1369
+
1370
+ The tool will automatically determine the winner or indicate if a revote is needed.
1371
+ """
1372
+ )
1373
+
1374
+ # Handle tie revotes if needed
1375
+ while self.trial_tie_nominees and not self.condemned:
1376
+ tied = self.trial_tie_nominees
1377
+ self.trial_tie_nominees = []
1378
+
1379
+ for voter in self.get_alive_players():
1380
+ await self.step_agent(
1381
+ voter.persona.name,
1382
+ broadcast=False,
1383
+ targets=["Narrator"],
1384
+ payload=f"""
1385
+ [PRIVATE - Only the Narrator can see this]
1386
+ === TRIAL REVOTE ===
1387
+ There was a tie. Vote again, choosing ONLY from: {", ".join(tied)}
1388
+
1389
+ Your response must end with: "I vote for [nominee_name]"
1390
+ """,
1391
+ )
1392
+
1393
+ await self.step_narrator(
1394
+ payload=f"""
1395
+ Use record_trial_vote to tally the revote. Only votes for {", ".join(tied)} are valid.
1396
+ """
1397
+ )
1398
+
1399
+ async def run_gallows_phase(self) -> None:
1400
+ """Run the gallows phase: final speech and execution vote."""
1401
+ self.phase = GamePhase.GALLOWS
1402
+
1403
+ if not self.condemned:
1404
+ return
1405
+
1406
+ condemned_role = self.get_player_role(self.condemned)
1407
+
1408
+ # Narrator narrates walk to gallows
1409
+ await self.step_narrator(
1410
+ payload=f"""
1411
+ === GALLOWS PHASE ===
1412
+ {self.condemned} has been condemned.
1413
+
1414
+ Narrate their walk to the gallows dramatically. Set a somber, tense atmosphere.
1415
+ Then allow them to give their final words.
1416
+ """
1417
+ )
1418
+
1419
+ # Condemned gives final speech (broadcast)
1420
+ await self.step_agent(
1421
+ self.condemned,
1422
+ broadcast=True,
1423
+ payload="""
1424
+ === FINAL WORDS ===
1425
+ You have been condemned to the gallows.
1426
+ This may be your last chance to speak.
1427
+
1428
+ Give your final speech. You might:
1429
+ - Proclaim your innocence
1430
+ - Reveal information
1431
+ - Accuse others
1432
+ - Accept your fate
1433
+
1434
+ Everyone can hear your final words.
1435
+ """,
1436
+ )
1437
+
1438
+ # Execution vote - everyone except condemned
1439
+ await self.step_narrator(
1440
+ payload=f"""
1441
+ Now each player (except {self.condemned}) will vote to execute or spare.
1442
+ Ask each player for their vote.
1443
+ """
1444
+ )
1445
+
1446
+ voters = [
1447
+ p for p in self.get_alive_players() if p.persona.name != self.condemned
1448
+ ]
1449
+ for voter in voters:
1450
+ await self.step_agent(
1451
+ voter.persona.name,
1452
+ broadcast=False,
1453
+ targets=["Narrator"],
1454
+ payload=f"""
1455
+ [PRIVATE - Only the Narrator can see this]
1456
+ === EXECUTION VOTE ===
1457
+ Vote to execute or spare {self.condemned}.
1458
+
1459
+ Your response must end with your vote: "execute" or "spare"
1460
+ """,
1461
+ )
1462
+
1463
+ # Narrator records vote and narrates outcome
1464
+ await self.step_narrator(
1465
+ payload=f"""
1466
+ Record execution votes using record_vote(execute_names, spare_names).
1467
+ Majority decides the outcome.
1468
+
1469
+ If executed:
1470
+ - Narrate the execution dramatically
1471
+ - Reveal {self.condemned}'s role: {condemned_role}
1472
+ - {"NOTE: This is the JESTER! They win if executed!" if condemned_role == "Jester" else ""}
1473
+
1474
+ If spared:
1475
+ - Narrate their release
1476
+ - Do NOT reveal their role
1477
+
1478
+ Don't narrate the start of the next night phase, or if any win condition is met. You will intro the next phase (or narrate the end of the game) in your next message, not this one.
1479
+ """
1480
+ )
1481
+
1482
+ # Check if execution happened (majority voted execute)
1483
+ execute_count = len(self.current_votes_for)
1484
+ spare_count = len(self.current_votes_against)
1485
+
1486
+ if execute_count > spare_count:
1487
+ # Execution happens
1488
+ if condemned_role == "Jester":
1489
+ self.jester_executed = self.condemned
1490
+ self.kill_player(self.condemned)
1491
+
1492
+ # ==================== Main Game Loop ====================
1493
+
1494
+ async def run(self) -> WinCondition:
1495
+ """Run the complete game loop."""
1496
+ await self.start_game()
1497
+ await asyncio.sleep(2)
1498
+
1499
+ while self.check_win_condition() == WinCondition.NONE:
1500
+ self.day_number += 1
1501
+
1502
+ await self.run_night_phase()
1503
+
1504
+ if self.check_win_condition() != WinCondition.NONE:
1505
+ break
1506
+
1507
+ await self.run_day_phase()
1508
+
1509
+ await self.announce_winner()
1510
+ return self.winner
1511
+
1512
+ async def announce_winner(self) -> None:
1513
+ """Announce the game winner."""
1514
+ self.phase = GamePhase.GAME_OVER
1515
+
1516
+ winner_text = ""
1517
+ if self.winner == WinCondition.TOWN_WINS:
1518
+ winner_text = "The TOWN has won! All Mafia members have been eliminated."
1519
+ elif self.winner == WinCondition.MAFIA_WINS:
1520
+ winner_text = "The MAFIA has won! They have achieved parity with the town."
1521
+ elif self.winner == WinCondition.JESTER_WINS:
1522
+ winner_text = f"The JESTER ({self.jester_executed}) has won! They were executed by the town."
1523
+
1524
+ await self.step_narrator(
1525
+ payload=f"""
1526
+ === GAME OVER ===
1527
+ {winner_text}
1528
+
1529
+ Final role reveals:
1530
+ {self.get_role_assignments_str()}
1531
+
1532
+ Survivors: {", ".join(self.get_alive_names()) if self.get_alive_names() else "None"}
1533
+ Deaths: {", ".join([p.persona.name for p in self.get_dead_players()])}
1534
+
1535
+ Narrate an epic conclusion to the game. Reveal all roles and provide closure to the story.
1536
+ """
1537
+ )