From d389b6e877c18123c356096cf751107a55da1705 Mon Sep 17 00:00:00 2001 From: "Keyu(Frank) He" Date: Sun, 21 Sep 2025 01:10:35 -0400 Subject: [PATCH 01/21] werewolf game in progress with minor bugs, will fix in future iterations --- .../experimental/werewolves/game_rules.json | 215 ++++ examples/experimental/werewolves/main.py | 255 +++++ .../experimental/werewolves/role_actions.json | 75 ++ examples/experimental/werewolves/roster.json | 61 ++ sotopia/database/persistent_profile.py | 4 + sotopia/envs/__init__.py | 3 +- sotopia/envs/social_game.py | 933 ++++++++++++++++++ sotopia/samplers/uniform_sampler.py | 16 +- 8 files changed, 1559 insertions(+), 3 deletions(-) create mode 100644 examples/experimental/werewolves/game_rules.json create mode 100644 examples/experimental/werewolves/main.py create mode 100644 examples/experimental/werewolves/role_actions.json create mode 100644 examples/experimental/werewolves/roster.json create mode 100644 sotopia/envs/social_game.py diff --git a/examples/experimental/werewolves/game_rules.json b/examples/experimental/werewolves/game_rules.json new file mode 100644 index 000000000..02a602519 --- /dev/null +++ b/examples/experimental/werewolves/game_rules.json @@ -0,0 +1,215 @@ +{ + "initial_phase": "night_werewolves", + "phases": [ + { + "name": "night_werewolves", + "kind": "team_target", + "turn_mode": "simultaneous", + "acting_roles": [ + "Werewolf" + ], + "acting_teams": [ + "Werewolves" + ], + "speech_visibility": "team", + "action_visibility": "team", + "resolution": { + "operation": "store_target", + "state_key": "night_target", + "visibility": "team" + }, + "entry_messages": [ + "Night phase: werewolves pick a target." + ], + "exit_messages": [ + "Werewolves have chosen their move." + ], + "group": "night", + "instructions": [ + "Secret night phase. Only werewolves act here." + ], + "role_instructions": { + "Werewolf": [ + "Coordinate quietly with packmates and issue 'kill NAME'." + ] + } + }, + { + "name": "night_seer", + "kind": "single_target", + "turn_mode": "single", + "acting_roles": [ + "Seer" + ], + "speech_visibility": "private", + "action_visibility": "private", + "resolution": { + "operation": "seer_inspect", + "visibility": "private" + }, + "entry_messages": [ + "Seer, choose someone to inspect." + ], + "exit_messages": [ + "Seer's vision is complete." + ], + "group": "night", + "instructions": [ + "Seer takes a private action." + ], + "role_instructions": { + "Seer": [ + "Use 'inspect NAME' to learn their alignment." + ] + } + }, + { + "name": "night_witch", + "kind": "single_target", + "turn_mode": "single", + "acting_roles": [ + "Witch" + ], + "speech_visibility": "private", + "action_visibility": "private", + "resolution": { + "operation": "witch_phase", + "visibility": "private" + }, + "entry_messages": [ + "Witch, decide to save, poison, or pass." + ], + "exit_messages": [ + "Witch phase ends." + ], + "group": "night", + "instructions": [ + "Witch decides whether to intervene." + ], + "role_instructions": { + "Witch": [ + "Choose 'save NAME', 'poison NAME', or 'pass'. Each potion may be used once." + ] + } + }, + { + "name": "dawn_report", + "kind": "announcement", + "turn_mode": "simultaneous", + "resolution": { + "operation": "resolve_night", + "visibility": "public" + }, + "entry_messages": [ + "Dawn report:" + ], + "exit_messages": [], + "group": "night", + "instructions": [ + "Public summary of night outcomes." + ], + "role_instructions": {} + }, + { + "name": "day_discussion", + "kind": "discussion", + "turn_mode": "round-robin", + "acting_roles": [ + "Villager", + "Seer", + "Witch", + "Werewolf" + ], + "max_cycles": 2, + "max_turns": 12, + "speech_visibility": "public", + "action_visibility": "public", + "resolution": { + "operation": "noop" + }, + "entry_messages": [ + "Day discussion starts. Speak in turn." + ], + "exit_messages": [ + "Discussion ends." + ], + "group": "day", + "instructions": [ + "Each villager speaks in turn. Share concise reasoning tied to observations." + ], + "role_instructions": {} + }, + { + "name": "day_vote", + "kind": "vote", + "turn_mode": "simultaneous", + "acting_roles": [ + "Villager", + "Seer", + "Witch", + "Werewolf" + ], + "speech_visibility": "hidden", + "action_visibility": "public", + "resolution": { + "operation": "vote", + "visibility": "public" + }, + "entry_messages": [ + "Voting phase: use 'vote NAME' or 'vote none'." + ], + "exit_messages": [ + "Votes are tallied." + ], + "group": "day", + "instructions": [ + "Voting phase: respond with action 'vote NAME' or 'vote none'." + ], + "role_instructions": {} + }, + { + "name": "twilight_execution", + "kind": "announcement", + "turn_mode": "simultaneous", + "resolution": { + "operation": "post_vote_cleanup", + "visibility": "public" + }, + "entry_messages": [ + "Execution results:" + ], + "exit_messages": [ + "Night returns." + ], + "group": "day", + "instructions": [ + "Resolve the vote and announce results." + ], + "role_instructions": {} + } + ], + "phase_transitions": { + "night_werewolves": "night_seer", + "night_seer": "night_witch", + "night_witch": "dawn_report", + "dawn_report": "day_discussion", + "day_discussion": "day_vote", + "day_vote": "twilight_execution", + "twilight_execution": "night_werewolves" + }, + "end_conditions": [ + { + "operation": "team_eliminated", + "team": "Werewolves", + "winner": "Villagers", + "message": "[God] Villagers win; no werewolves remain." + }, + { + "operation": "parity", + "team": "Werewolves", + "other_team": "Villagers", + "winner": "Werewolves", + "message": "[God] Werewolves win; they now match the village." + } + ] +} diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py new file mode 100644 index 000000000..50b9422c6 --- /dev/null +++ b/examples/experimental/werewolves/main.py @@ -0,0 +1,255 @@ +"""Launcher for the Duskmire Werewolves social game scenario.""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Dict, List + +import redis + +from sotopia.agents import LLMAgent +from sotopia.database.persistent_profile import ( + AgentProfile, + EnvironmentProfile, + RelationshipType, +) +from sotopia.envs import SocialGameEnv +from sotopia.envs.evaluators import ( + EpisodeLLMEvaluator, + EvaluationForAgents, + RuleBasedTerminatedEvaluator, +) +from sotopia.server import arun_one_episode +from sotopia.database import SotopiaDimensions + +BASE_DIR = Path(__file__).resolve().parent +ROLE_ACTIONS_PATH = BASE_DIR / "role_actions.json" +RULEBOOK_PATH = BASE_DIR / "game_rules.json" +ROSTER_PATH = BASE_DIR / "roster.json" + +os.environ.setdefault("REDIS_OM_URL", "redis://:@localhost:6379") +redis.Redis(host="localhost", port=6379) + +COMMON_GUIDANCE = ( + "During your turn you must respond. If 'action' is available, use commands like 'kill NAME', " + "'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Werewolf night speech is private to the pack. " + "Day discussion is public. Voting requires an 'action' beginning with 'vote'." +) + + +def load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text()) + + +def ensure_agent(player: Dict[str, Any]) -> AgentProfile: + try: + profile = AgentProfile.find( + AgentProfile.first_name == player["first_name"], + AgentProfile.last_name == player["last_name"], + ).all()[0] + return profile # type: ignore[return-value] + except IndexError: + profile = AgentProfile( + first_name=player["first_name"], + last_name=player["last_name"], + age=player.get("age", 30), + occupation="", + gender="", + gender_pronoun=player.get("pronouns", "they/them"), + public_info="", + personality_and_values="", + decision_making_style="", + secret=player.get("secret", ""), + ) + profile.save() + return profile + + +def build_agent_goal(player: Dict[str, Any], role_prompt: str) -> str: + return ( + f"You are {player['first_name']} {player['last_name']}, publicly known only as a villager.\n" + f"Primary directives: {player['goal']}\n" + f"Role guidance: {role_prompt}\n" + f"System constraints: {COMMON_GUIDANCE}" + ) + + +def prepare_scenario() -> tuple[EnvironmentProfile, List[AgentProfile], Dict[str, str]]: + role_actions = load_json(ROLE_ACTIONS_PATH) + roster = load_json(ROSTER_PATH) + + agents: List[AgentProfile] = [] + agent_goals: List[str] = [] + role_assignments: Dict[str, str] = {} + + for player in roster["players"]: + profile = ensure_agent(player) + agents.append(profile) + full_name = f"{player['first_name']} {player['last_name']}" + role = player["role"] + role_prompt = role_actions["roles"][role]["goal_prompt"] + agent_goals.append(build_agent_goal(player, role_prompt)) + role_assignments[full_name] = role + + scenario_text = ( + roster["scenario"] + + " Werewolves must be eliminated before they achieve parity with villagers." + ) + + env_profile = EnvironmentProfile( + scenario=scenario_text, + agent_goals=agent_goals, + relationship=RelationshipType.acquaintance, + game_metadata={ + "mode": "social_game", + "rulebook_path": str(RULEBOOK_PATH), + "actions_path": str(ROLE_ACTIONS_PATH), + "role_assignments": role_assignments, + }, + tag="werewolves", + ) + env_profile.save() + return env_profile, agents, role_assignments + + +def build_environment( + env_profile: EnvironmentProfile, + role_assignments: Dict[str, str], + model_name: str, +) -> SocialGameEnv: + return SocialGameEnv( + env_profile=env_profile, + rulebook_path=str(RULEBOOK_PATH), + actions_path=str(ROLE_ACTIONS_PATH), + role_assignments=role_assignments, + model_name=model_name, + action_order="round-robin", + evaluators=[RuleBasedTerminatedEvaluator(max_turn_number=40, max_stale_turn=2)], + terminal_evaluators=[ + EpisodeLLMEvaluator( + model_name, + EvaluationForAgents[SotopiaDimensions], + ) + ], + ) + + +def create_agents( + agent_profiles: List[AgentProfile], + env_profile: EnvironmentProfile, + model_names: List[str], +) -> List[LLMAgent]: + agents: List[LLMAgent] = [] + for profile, model_name, goal in zip( + agent_profiles, + model_names, + env_profile.agent_goals, + strict=True, + ): + agent = LLMAgent(agent_profile=profile, model_name=model_name) + agent.goal = goal + agents.append(agent) + return agents + + +def summarize_phase_log(phase_log: List[Dict[str, Any]]) -> None: + if not phase_log: + print("\nNo structured events recorded.") + return + + print("\nTimeline by Phase") + print("=" * 60) + + last_label: str | None = None + for entry in phase_log: + phase_name = entry["phase"] + meta = entry.get("meta", {}) + group = meta.get("group") + cycle = meta.get("group_cycle") + stage = meta.get("group_stage") + title = phase_name.replace("_", " ").title() + if group: + group_label = group.replace("_", " ").title() + if cycle and stage: + label = f"{group_label} {cycle}.{stage} – {title}" + elif cycle: + label = f"{group_label} {cycle} – {title}" + else: + label = f"{group_label}: {title}" + else: + label = title + + if label != last_label: + print(f"\n[{label}]") + last_label = label + instructions = entry.get("instructions", []) + for info_line in instructions: + print(f" Info: {info_line}") + role_instr = entry.get("role_instructions", {}) + for role, lines in role_instr.items(): + for line in lines: + print(f" Role {role}: {line}") + + for msg in entry.get("public", []): + print(f" Public: {msg}") + for team, messages in entry.get("team", {}).items(): + for msg in messages: + print(f" Team ({team}) private: {msg}") + for agent, messages in entry.get("private", {}).items(): + for msg in messages: + print(f" Private to {agent}: {msg}") + for actor, action in entry.get("actions", {}).items(): + print( + f" Action logged: {actor} -> {action['action_type']} {action['argument']}" + ) + + +def print_roster(role_assignments: Dict[str, str]) -> None: + print("Participants & roles:") + for name, role in role_assignments.items(): + print(f" - {name}: {role}") + + +async def main() -> None: + env_profile, agent_profiles, role_assignments = prepare_scenario() + env_model = "gpt-4o-mini" + agent_model_list = [ + "gpt-4o-mini", + "gpt-4o-mini", + "gpt-4o-mini", + "gpt-4o-mini", + "gpt-4o-mini", + "gpt-4o-mini", + ] + + env = build_environment(env_profile, role_assignments, env_model) + agents = create_agents(agent_profiles, env_profile, agent_model_list) + + print("🌕 Duskmire Werewolves — Structured Social Game") + print("=" * 60) + print_roster(role_assignments) + print("=" * 60) + + await arun_one_episode( + env=env, + agent_list=agents, + omniscient=False, + script_like=False, + json_in_script=False, + tag=None, + push_to_db=False, + ) + + summarize_phase_log(env.phase_log) + + if env._winner_payload: # noqa: SLF001 (internal inspection for demo) + print("\nGame Result:") + print(f"Winner: {env._winner_payload['winner']}") + print(f"Reason: {env._winner_payload['message']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/experimental/werewolves/role_actions.json b/examples/experimental/werewolves/role_actions.json new file mode 100644 index 000000000..2c88851a9 --- /dev/null +++ b/examples/experimental/werewolves/role_actions.json @@ -0,0 +1,75 @@ +{ + "roles": { + "Villager": { + "name": "Villager", + "team": "Villagers", + "description": "Ordinary resident with no night power but vital voice in daytime debates.", + "goal_prompt": "Keep sharp notes about player behaviour and vote to execute suspected werewolves each day.", + "default_actions": ["speak"], + "phase_actions": { + "night_werewolves": ["none"], + "night_seer": ["none"], + "night_witch": ["none"], + "dawn_report": ["none"], + "day_discussion": ["speak"], + "day_vote": ["action"], + "twilight_execution": ["none"] + }, + "initial_state": {} + }, + "Seer": { + "name": "Seer", + "team": "Villagers", + "description": "Mystic who divines alignments during the night.", + "goal_prompt": "Inspect one player each night using an action like 'inspect NAME'; leak findings strategically without exposing yourself too early.", + "default_actions": ["speak"], + "phase_actions": { + "night_werewolves": ["none"], + "night_seer": ["action"], + "night_witch": ["none"], + "dawn_report": ["none"], + "day_discussion": ["speak"], + "day_vote": ["action"], + "twilight_execution": ["none"] + }, + "initial_state": {} + }, + "Witch": { + "name": "Witch", + "team": "Villagers", + "description": "Potion expert who may save one player per game and poison one player per game during the night.", + "goal_prompt": "During your witch phase, decide whether to 'save NAME', 'poison NAME', or pass. Use your limited potions wisely to keep villagers alive and remove wolves when confident.", + "default_actions": ["speak"], + "phase_actions": { + "night_werewolves": ["none"], + "night_seer": ["none"], + "night_witch": ["action"], + "dawn_report": ["none"], + "day_discussion": ["speak"], + "day_vote": ["action"], + "twilight_execution": ["none"] + }, + "initial_state": { + "save_available": true, + "poison_available": true + } + }, + "Werewolf": { + "name": "Werewolf", + "team": "Werewolves", + "description": "Predator hiding among villagers, coordinating nightly kills and sowing mistrust by day.", + "goal_prompt": "Confer quietly with fellow wolves at night. Use actions like 'kill NAME' to propose a victim. During the day, blend in while pushing misdirection.", + "default_actions": ["speak"], + "phase_actions": { + "night_werewolves": ["speak", "action"], + "night_seer": ["none"], + "night_witch": ["none"], + "dawn_report": ["none"], + "day_discussion": ["speak"], + "day_vote": ["action"], + "twilight_execution": ["none"] + }, + "initial_state": {} + } + } +} diff --git a/examples/experimental/werewolves/roster.json b/examples/experimental/werewolves/roster.json new file mode 100644 index 000000000..10aa70e57 --- /dev/null +++ b/examples/experimental/werewolves/roster.json @@ -0,0 +1,61 @@ +{ + "scenario": "In Duskmire, six villagers gather each night to expose the hidden werewolves among them before the pack reaches equal numbers.", + "players": [ + { + "first_name": "Aurora", + "last_name": "Harper", + "role": "Villager", + "public_role": "Villager", + "age": 54, + "pronouns": "she/her", + "goal": "Keep discussion orderly and support executions only when evidence feels solid." + }, + { + "first_name": "Bram", + "last_name": "Nightshade", + "role": "Werewolf", + "public_role": "Villager", + "age": 33, + "pronouns": "he/him", + "goal": "Blend in with confident speech while steering suspicion toward ordinary villagers.", + "secret": "You are a werewolf working with Dorian. Coordinate night kills." + }, + { + "first_name": "Celeste", + "last_name": "Moonseer", + "role": "Seer", + "public_role": "Villager", + "age": 29, + "pronouns": "she/her", + "goal": "Inspect one player per night and nudge the village toward the wolves." + }, + { + "first_name": "Dorian", + "last_name": "Blackwood", + "role": "Werewolf", + "public_role": "Villager", + "age": 38, + "pronouns": "he/him", + "goal": "Support Bram's stories and pressure outspoken villagers into missteps.", + "secret": "You are a werewolf working with Bram. Coordinate night kills." + }, + { + "first_name": "Elise", + "last_name": "Farrow", + "role": "Witch", + "public_role": "Villager", + "age": 41, + "pronouns": "she/her", + "goal": "Use your save and poison sparingly; protect confirmed villagers and strike when a wolf is exposed." + }, + { + "first_name": "Finn", + "last_name": "Alder", + "role": "Villager", + "public_role": "Villager", + "age": 36, + "pronouns": "he/him", + "goal": "Track inconsistencies and rally the town to execute the most suspicious player each day." + } + ] +} diff --git a/sotopia/database/persistent_profile.py b/sotopia/database/persistent_profile.py index ab2f78fcb..23e1871e9 100644 --- a/sotopia/database/persistent_profile.py +++ b/sotopia/database/persistent_profile.py @@ -88,6 +88,10 @@ class BaseEnvironmentProfile(BaseModel): agent_constraint: list[list[str]] | None = Field( default_factory=lambda: None, ) + game_metadata: dict[str, Any] | None = Field( + default_factory=lambda: None, + description="Optional metadata for structured social games (rulebooks, config paths, etc.).", + ) tag: str = Field( index=True, default_factory=lambda: "", diff --git a/sotopia/envs/__init__.py b/sotopia/envs/__init__.py index fa56ad757..30b8d8a37 100644 --- a/sotopia/envs/__init__.py +++ b/sotopia/envs/__init__.py @@ -1,3 +1,4 @@ from .parallel import ParallelSotopiaEnv +from .social_game import SocialGameEnv -__all__ = ["ParallelSotopiaEnv"] +__all__ = ["ParallelSotopiaEnv", "SocialGameEnv"] diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py new file mode 100644 index 000000000..926e4ffc8 --- /dev/null +++ b/sotopia/envs/social_game.py @@ -0,0 +1,933 @@ +"""Social game environment that reads its rulebook and action space from JSON.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Optional, Sequence + +from pydantic import BaseModel, Field, RootModel, ValidationError + +from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent +from sotopia.messages import AgentAction, Observation, SimpleMessage + + +class RoleActionConfig(BaseModel): + """Declared abilities and messaging semantics for a specific role.""" + + name: str + team: str + description: str = "" + goal_prompt: str = "" + default_actions: list[str] = Field(default_factory=lambda: ["speak", "action"]) + phase_actions: dict[str, list[str]] = Field(default_factory=dict) + initial_state: dict[str, Any] = Field(default_factory=dict) + allow_team_private_speech: bool = False + allow_role_private_speech: bool = False + + +class RoleActionLibrary(RootModel[dict[str, RoleActionConfig]]): + """Pydantic wrapper for mapping roles to role metadata.""" + + def team_for_role(self, role: str) -> str: + return self.root[role].team + + +class PhaseResolution(BaseModel): + operation: str = Field( + default="noop", + description="Name of the builtin resolution handler to invoke at phase end.", + ) + state_key: str | None = None + visibility: str = Field( + default="public", + description="Default visibility for resolution feedback.", + ) + + +class PhaseDefinition(BaseModel): + name: str + kind: str = Field( + default="discussion", + description="Macro describing how the phase behaves (discussion, team_target, vote, single_target, announcement).", + ) + group: str | None = Field( + default=None, + description="Optional label used to cluster phases into higher-level cycles (e.g., 'night', 'day').", + ) + turn_mode: str = Field( + default="round-robin", + description="round-robin => sequential actors, simultaneous => everyone at once, single => one actor only.", + ) + acting_roles: list[str] | None = None + acting_teams: list[str] | None = None + max_cycles: int = Field( + default=1, + description="Number of complete round-robin passes required before the phase advances.", + ) + max_turns: int | None = Field( + default=None, + description="Optional cap on total turns inside the phase (overrides max_cycles when smaller).", + ) + speech_visibility: str = Field( + default="public", + description="Where speech is visible ('public', 'team', 'private', 'hidden').", + ) + action_visibility: str = Field( + default="public", + description="Where action outcomes are visible ('public', 'team', 'private', 'hidden').", + ) + instructions: list[str] = Field( + default_factory=list, + description="General prompts injected into agent observations for this phase.", + ) + role_instructions: dict[str, list[str]] = Field( + default_factory=dict, + description="Optional role-specific prompts keyed by role name.", + ) + resolution: PhaseResolution | None = None + entry_messages: list[str] = Field(default_factory=list) + exit_messages: list[str] = Field(default_factory=list) + description: str = "" + + +class EndConditionDefinition(BaseModel): + operation: str + team: str | None = None + other_team: str | None = None + winner: str | None = None + message: str | None = None + + +class RulebookConfig(BaseModel): + initial_phase: str + phases: list[PhaseDefinition] + phase_transitions: dict[str, str] + end_conditions: list[EndConditionDefinition] = Field(default_factory=list) + max_cycles: int | None = Field( + default=None, + description="Optional safety bound on day/night cycles to prevent infinite games.", + ) + + +@dataclass +class AgentState: + name: str + role: str + team: str + alive: bool = True + attributes: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PhaseEvents: + public: list[str] = field(default_factory=list) + team: dict[str, list[str]] = field(default_factory=dict) + private: dict[str, list[str]] = field(default_factory=dict) + system: list[str] = field(default_factory=list) + + def extend(self, other: "PhaseEvents") -> None: + self.public.extend(other.public) + for team, messages in other.team.items(): + self.team.setdefault(team, []).extend(messages) + for agent, messages in other.private.items(): + self.private.setdefault(agent, []).extend(messages) + self.system.extend(other.system) + + @classmethod + def phase_entry(cls, phase_name: str, messages: list[str]) -> "PhaseEvents": + events = cls() + for msg in messages: + events.public.append(f"[God] Phase '{phase_name}' begins: {msg}") + if not messages: + events.public.append(f"[God] Phase '{phase_name}' begins.") + return events + + +class GameRulebook: + """Runtime state machine that enforces the JSON described social game.""" + + def __init__(self, rules: RulebookConfig, roles: RoleActionLibrary) -> None: + self.rules = rules + self.roles = roles + self.phase_lookup = {phase.name: phase for phase in rules.phases} + self.agent_states: dict[str, AgentState] = {} + self.agent_name_lookup: dict[str, str] = {} + self.current_phase: str = rules.initial_phase + self.phase_cycle_progress: int = 0 + self.turns_in_phase: int = 0 + self.current_actor_index: int = 0 + self.state_flags: dict[str, Any] = {} + self.group_cycle: dict[str, int] = {} + self.group_stage: dict[str, int] = {} + self.current_phase_meta: dict[str, Any] = {} + self.pending_events: PhaseEvents = PhaseEvents() + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + def assign_agents( + self, + agents: Sequence[str], + role_assignments: dict[str, str], + ) -> None: + self.agent_states = {} + self.agent_name_lookup = {} + for name in agents: + role = role_assignments[name] + role_cfg = self.roles.root.get(role) + if role_cfg is None: + raise ValueError(f"Unknown role '{role}' for agent '{name}'") + attrs = dict(role_cfg.initial_state) + state = AgentState( + name=name, + role=role, + team=role_cfg.team, + alive=True, + attributes=attrs, + ) + self.agent_states[name] = state + self.agent_name_lookup[name.lower()] = name + self.agent_name_lookup[name.split()[0].lower()] = name + + self.current_phase = self.rules.initial_phase + self.phase_cycle_progress = 0 + self.turns_in_phase = 0 + self.current_actor_index = 0 + self.state_flags = { + "day_execution": None, + "night_target": None, + "witch_saved": None, + "witch_poisoned": None, + "seer_result": "", + } + self.group_cycle.clear() + self.group_stage.clear() + self.current_phase_meta = {} + self._register_phase_entry(self.current_phase) + entry_phase = self.phase_lookup[self.current_phase] + self.pending_events = PhaseEvents.phase_entry( + self.current_phase, entry_phase.entry_messages + ) + + # ------------------------------------------------------------------ + # Accessors used by the environment + # ------------------------------------------------------------------ + def alive_agents(self) -> list[str]: + return [name for name, state in self.agent_states.items() if state.alive] + + def active_agents_for_phase(self) -> list[str]: + phase = self.phase_lookup[self.current_phase] + eligible = self._eligible_candidates(phase) + if not eligible: + return [] + if phase.turn_mode == "round-robin": + idx = self.current_actor_index + if idx >= len(eligible): + idx = len(eligible) - 1 + if idx < 0: + idx = 0 + return [eligible[idx]] + return eligible + + def available_actions(self, agent_name: str) -> list[str]: + agent_state = self.agent_states[agent_name] + if not agent_state.alive: + return ["none"] + role_cfg = self.roles.root[agent_state.role] + actions = role_cfg.phase_actions.get( + self.current_phase, role_cfg.default_actions + ) + if "none" not in actions: + actions = list(actions) + ["none"] + return actions + + def collect_pending_events(self) -> PhaseEvents: + events = self.pending_events + self.pending_events = PhaseEvents() + return events + + # ------------------------------------------------------------------ + # Core update logic + # ------------------------------------------------------------------ + def process_actions( + self, actions: dict[str, AgentAction] + ) -> tuple[PhaseEvents, bool, Optional[dict[str, str]]]: + phase = self.phase_lookup[self.current_phase] + acting_agents = self.active_agents_for_phase() + events = PhaseEvents() + + if phase.kind == "announcement": + events.extend(self._resolve_phase(phase, {})) + winner = self._check_end_conditions() + self._schedule_phase_exit(phase) + return events, True, winner + + if not acting_agents: + events.extend(self._resolve_phase(phase, {})) + winner = self._check_end_conditions() + self._schedule_phase_exit(phase) + return events, True, winner + + relevant = { + name: actions.get(name, AgentAction(action_type="none", argument="")) + for name in acting_agents + } + + if phase.turn_mode == "round-robin": + actor = acting_agents[0] + events.extend(self._record_speech(actor, relevant[actor], phase)) + events.extend(self._resolve_phase(phase, {actor: relevant[actor]})) + self._advance_round_robin(phase) + advance = self._should_advance(phase) + else: + for actor, action in relevant.items(): + events.extend(self._record_speech(actor, action, phase)) + events.extend(self._resolve_phase(phase, relevant)) + advance = True + + winner = self._check_end_conditions() + if winner: + self._schedule_phase_exit(phase) + return events, True, winner + + if advance: + self._schedule_phase_exit(phase) + return events, advance, winner + + def start_next_phase(self) -> PhaseEvents: + next_phase = self.rules.phase_transitions.get(self.current_phase) + if next_phase is None: + raise ValueError( + f"No transition defined after phase '{self.current_phase}'" + ) + self.current_phase = next_phase + self.phase_cycle_progress = 0 + self.turns_in_phase = 0 + self.current_actor_index = 0 + self._register_phase_entry(next_phase) + phase_def = self.phase_lookup[next_phase] + entry = PhaseEvents.phase_entry(next_phase, phase_def.entry_messages) + return entry + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _phase_group(self, phase: PhaseDefinition) -> str: + if phase.group: + return phase.group + return phase.name + + def _register_phase_entry(self, phase_name: str) -> None: + phase = self.phase_lookup[phase_name] + group = self._phase_group(phase) + previous_group = ( + self.current_phase_meta.get("group") if self.current_phase_meta else None + ) + cycle = self.group_cycle.get(group, 0) + stage = self.group_stage.get(group, 0) + if previous_group != group: + cycle += 1 + stage = 1 + else: + stage += 1 + self.group_cycle[group] = cycle + self.group_stage[group] = stage + self.current_phase_meta = { + "phase": phase_name, + "group": group, + "group_cycle": cycle, + "group_stage": stage, + "display_name": phase.name.replace("_", " ").title(), + } + + def current_phase_metadata(self) -> dict[str, Any]: + return dict(self.current_phase_meta) if self.current_phase_meta else {} + + def _eligible_candidates(self, phase: PhaseDefinition) -> list[str]: + names = [name for name, state in self.agent_states.items() if state.alive] + if phase.acting_roles: + names = [ + name + for name in names + if self.agent_states[name].role in phase.acting_roles + ] + if phase.acting_teams: + names = [ + name + for name in names + if self.agent_states[name].team in phase.acting_teams + ] + return names + + def _record_speech( + self, actor: str, action: AgentAction, phase: PhaseDefinition + ) -> PhaseEvents: + events = PhaseEvents() + if action.action_type not in {"speak", "non-verbal communication"}: + return events + utterance = action.argument.strip() + if not utterance: + return events + line = f'{actor} said: "{utterance}"' + if phase.speech_visibility == "team": + team = self.agent_states[actor].team + events.team.setdefault(team, []).append(line) + elif phase.speech_visibility == "private": + events.private.setdefault(actor, []).append(line) + elif phase.speech_visibility == "hidden": + pass + else: + events.public.append(line) + return events + + def _resolve_phase( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + ) -> PhaseEvents: + if phase.resolution is None: + return PhaseEvents() + handler = getattr(self, f"_resolve_{phase.resolution.operation}", None) + if handler is None: + raise ValueError( + f"Unsupported resolution operation '{phase.resolution.operation}'" + ) + return handler(phase, actions, phase.resolution) + + def _resolve_noop( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + return PhaseEvents() + + def _resolve_store_target( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + target = self._extract_target(actions.values()) + if target: + self.state_flags[resolution.state_key or "night_target"] = target + teams = phase.acting_teams or [self.agent_states[a].team for a in actions] + for team in teams: + events.team.setdefault(team, []).append( + f"[God] Target locked: {target}." + ) + return events + + def _resolve_seer_inspect( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + if not actions: + return events + actor, action = next(iter(actions.items())) + target = self._extract_target([action]) + if not target: + events.private.setdefault(actor, []).append( + "[God] Vision failed: unable to interpret your target." + ) + return events + team = self.agent_states[target].team + message = f"[God] Vision reveals {target} serves team {team}." + events.private.setdefault(actor, []).append(message) + self.state_flags["seer_result"] = message + return events + + def _resolve_witch_phase( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + if not actions: + return events + actor, action = next(iter(actions.items())) + state = self.agent_states[actor] + text = action.argument.lower() + if "save" in text and state.attributes.get("save_available", True): + target = self._extract_target([action]) or self.state_flags.get( + "night_target" + ) + if target: + self.state_flags["witch_saved"] = target + state.attributes["save_available"] = False + events.private.setdefault(actor, []).append( + f"[God] You secretly saved {target} tonight." + ) + if "poison" in text and state.attributes.get("poison_available", True): + target = self._extract_target([action]) + if target: + self.state_flags["witch_poisoned"] = target + state.attributes["poison_available"] = False + events.private.setdefault(actor, []).append( + f"[God] You poisoned {target}." + ) + if not text.strip() or "pass" in text: + events.private.setdefault(actor, []).append( + "[God] You chose to remain idle." + ) + return events + + def _resolve_resolve_night( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + saved = self.state_flags.get("witch_saved") + target = self.state_flags.get("night_target") + poison = self.state_flags.get("witch_poisoned") + casualties: list[str] = [] + if target and target != saved: + casualties.append(target) + if poison and poison not in casualties: + casualties.append(poison) + if not casualties: + events.public.append("[God] Dawn breaks peacefully. No one died.") + for victim in casualties: + if victim in self.agent_states and self.agent_states[victim].alive: + self.agent_states[victim].alive = False + events.public.append(f"[God] {victim} was found dead at dawn.") + self.state_flags["night_target"] = None + self.state_flags["witch_saved"] = None + self.state_flags["witch_poisoned"] = None + self.state_flags["seer_result"] = "" + return events + + def _resolve_vote( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + tally: dict[str, int] = {} + for action in actions.values(): + target = self._extract_target([action]) + if target: + tally[target] = tally.get(target, 0) + 1 + elif "none" in action.argument.lower(): + tally.setdefault("none", 0) + tally["none"] += 1 + if not tally: + events.public.append("[God] No valid votes were cast.") + self.state_flags["day_execution"] = None + return events + winner, votes = max(tally.items(), key=lambda kv: kv[1]) + if winner == "none": + events.public.append("[God] The town decided to stay their hand.") + self.state_flags["day_execution"] = None + return events + if list(tally.values()).count(votes) > 1: + events.public.append("[God] The vote is tied. No execution today.") + self.state_flags["day_execution"] = None + return events + self.state_flags["day_execution"] = winner + events.public.append( + f"[God] Majority condemns {winner}. Execution will happen at twilight." + ) + return events + + def _resolve_post_vote_cleanup( + self, + phase: PhaseDefinition, + actions: dict[str, AgentAction], + resolution: PhaseResolution, + ) -> PhaseEvents: + events = PhaseEvents() + target = self.state_flags.get("day_execution") + if target and target in self.agent_states and self.agent_states[target].alive: + self.agent_states[target].alive = False + team = self.agent_states[target].team + events.public.append( + f"[God] {target} was executed. They belonged to team {team}." + ) + self.state_flags["day_execution"] = None + return events + + def _extract_target(self, actions: Iterable[AgentAction]) -> str | None: + for action in actions: + corpus = f"{action.action_type} {action.argument}".lower() + for name in self.agent_states: + if name.lower() in corpus: + return name + for name in self.agent_states: + first = name.split()[0].lower() + if first in corpus: + return name + return None + + def _advance_round_robin(self, phase: PhaseDefinition) -> None: + base = self._eligible_candidates(phase) + self.turns_in_phase += 1 + if not base: + self.current_actor_index = 0 + return + self.current_actor_index += 1 + if self.current_actor_index >= len(base): + self.phase_cycle_progress += 1 + self.current_actor_index = 0 + + def _should_advance(self, phase: PhaseDefinition) -> bool: + if phase.turn_mode != "round-robin": + return True + base = self._eligible_candidates(phase) + if not base: + return True + if phase.max_turns is not None and self.turns_in_phase >= phase.max_turns: + return True + if self.phase_cycle_progress >= phase.max_cycles: + return True + return False + + def _schedule_phase_exit(self, phase: PhaseDefinition) -> None: + exit_events = PhaseEvents() + for msg in phase.exit_messages: + exit_events.public.append(f"[God] {msg}") + self.pending_events.extend(exit_events) + + def _check_end_conditions(self) -> Optional[dict[str, str]]: + for cond in self.rules.end_conditions: + if cond.operation == "team_eliminated" and cond.team: + alive = sum( + 1 + for state in self.agent_states.values() + if state.alive and state.team == cond.team + ) + if alive == 0: + message = ( + cond.message or f"[God] Team {cond.team} has been eliminated." + ) + return { + "winner": cond.winner or cond.other_team or cond.team, + "message": message, + } + if cond.operation == "parity" and cond.team and cond.other_team: + team_count = sum( + 1 + for state in self.agent_states.values() + if state.alive and state.team == cond.team + ) + other_count = sum( + 1 + for state in self.agent_states.values() + if state.alive and state.team == cond.other_team + ) + if team_count >= other_count: + message = cond.message or ( + f"[God] Parity reached: {cond.team} now matches or exceeds {cond.other_team}." + ) + return { + "winner": cond.winner or cond.team, + "message": message, + } + return None + + +class SocialGameEnv(ParallelSotopiaEnv): + """Environment subclass that enforces multi-phase social game mechanics.""" + + def __init__( + self, + env_profile, + *, + rulebook_path: str, + actions_path: str, + role_assignments: dict[str, str], + **kwargs: Any, + ) -> None: + super().__init__(env_profile=env_profile, **kwargs) + self._rulebook_path = Path(rulebook_path) + self._actions_path = Path(actions_path) + self._role_assignments = role_assignments + self.game_rulebook: GameRulebook | None = None + self._last_events: PhaseEvents = PhaseEvents() + self._winner_payload: dict[str, str] | None = None + self.phase_log: list[dict[str, Any]] = [] + + # ------------------------------------------------------------------ + # Config loading helpers + # ------------------------------------------------------------------ + def _load_configs(self) -> tuple[RulebookConfig, RoleActionLibrary]: + try: + rules = RulebookConfig.model_validate_json(self._rulebook_path.read_text()) + except ValidationError as exc: + raise ValueError(f"Invalid rulebook config: {exc}") from exc + actions_raw = json.loads(self._actions_path.read_text()) + try: + roles = RoleActionLibrary.model_validate(actions_raw["roles"]) + except (KeyError, ValidationError) as exc: + raise ValueError(f"Invalid action-space config: {exc}") from exc + return rules, roles + + # ------------------------------------------------------------------ + # Overrides + # ------------------------------------------------------------------ + def reset( + self, + seed: int | None = None, + options: dict[str, str] | None = None, + agents=None, + omniscient: bool = False, + lite: bool = False, + ) -> dict[str, Observation]: + base_obs = super().reset( + seed=seed, + options=options, + agents=agents, + omniscient=omniscient, + lite=lite, + ) + rules, role_actions = self._load_configs() + self.game_rulebook = GameRulebook(rules, role_actions) + self.game_rulebook.assign_agents(self.agents, self._role_assignments) + self.phase_log = [] + self._apply_action_mask() + self._last_events = self.game_rulebook.collect_pending_events() + self._winner_payload = None + self._record_phase_history( + phase_name=self.game_rulebook.current_phase, + actions={}, + events=self._last_events, + ) + return self._augment_observations(base_obs, append_to_existing=True) + + def _phase_prompt_lines( + self, + *, + agent_name: str, + phase: PhaseDefinition, + acting: bool, + available: list[str], + ) -> list[str]: + assert self.game_rulebook is not None + meta = self.game_rulebook.current_phase_metadata() + group = meta.get("group") + cycle = meta.get("group_cycle") + stage = meta.get("group_stage") + title = phase.name.replace("_", " ").title() + if group: + group_label = group.replace("_", " ").title() + if cycle and stage: + label = f"{group_label} {cycle}.{stage} – {title}" + elif cycle: + label = f"{group_label} {cycle} – {title}" + else: + label = f"{group_label}: {title}" + else: + label = title + lines = [f"[God] Phase: {label}"] + if acting: + lines.append("[God] It is your turn to act in this phase.") + else: + lines.append("[God] You are observing while others act.") + lines.append(f"[God] Available actions right now: {', '.join(available)}") + lines.extend(f"[God] {text}" for text in phase.instructions) + role = self.game_rulebook.agent_states[agent_name].role + for text in phase.role_instructions.get(role, []): + lines.append(f"[God] {text}") + return lines + + def _record_phase_history( + self, + *, + phase_name: str, + actions: dict[str, AgentAction], + events: PhaseEvents, + ) -> None: + if self.game_rulebook is None: + return + if not (events.public or events.team or events.private): + if any(a.action_type != "none" for a in actions.values()): + pass + else: + return + action_summary = { + agent: {"action_type": action.action_type, "argument": action.argument} + for agent, action in actions.items() + if action.action_type != "none" + } + phase_def = ( + self.game_rulebook.phase_lookup.get(phase_name) + if self.game_rulebook + else None + ) + snapshot = { + "phase": phase_name, + "turn": self.turn_number, + "public": list(events.public), + "team": {team: list(msgs) for team, msgs in events.team.items()}, + "private": {agent: list(msgs) for agent, msgs in events.private.items()}, + "actions": action_summary, + "meta": self.game_rulebook.current_phase_metadata() + if self.game_rulebook + else {}, + "instructions": phase_def.instructions if phase_def else [], + "role_instructions": phase_def.role_instructions if phase_def else {}, + } + self.phase_log.append(snapshot) + + def _augment_observations( + self, + baseline: dict[str, Observation], + *, + append_to_existing: bool, + ) -> dict[str, Observation]: + assert self.game_rulebook is not None + acting = set(self.game_rulebook.active_agents_for_phase()) + events = self._last_events + phase_name = self.game_rulebook.current_phase + phase_def = self.game_rulebook.phase_lookup[phase_name] + new_obs: dict[str, Observation] = {} + for idx, agent_name in enumerate(self.agents): + current = baseline[agent_name] + available = ( + self.game_rulebook.available_actions(agent_name) + if agent_name in acting + else ["none"] + ) + phase_lines = self._phase_prompt_lines( + agent_name=agent_name, + phase=phase_def, + acting=agent_name in acting, + available=available, + ) + messages: list[str] = [] + messages.extend(events.public) + team = self.game_rulebook.agent_states[agent_name].team + messages.extend(events.team.get(team, [])) + messages.extend(events.private.get(agent_name, [])) + if not messages: + messages.append("[God] Await instructions from the host.") + segments: list[str] = [] + if append_to_existing: + prefix = current.last_turn.strip() + if prefix: + segments.append(prefix) + segments.extend(phase_lines) + segments.extend(messages) + combined = "\n".join(segment for segment in segments if segment) + new_obs[agent_name] = Observation( + last_turn=render_text_for_agent(combined, agent_id=idx), + turn_number=current.turn_number, + available_actions=available, + ) + return new_obs + + def _create_blank_observations(self) -> dict[str, Observation]: + assert self.game_rulebook is not None + acting = set(self.game_rulebook.active_agents_for_phase()) + blank: dict[str, Observation] = {} + for agent_name in self.agents: + available = ( + self.game_rulebook.available_actions(agent_name) + if agent_name in acting + else ["none"] + ) + blank[agent_name] = Observation( + last_turn="", + turn_number=self.turn_number, + available_actions=available, + ) + return blank + + def _apply_action_mask(self) -> None: + assert self.game_rulebook is not None + acting = set(self.game_rulebook.active_agents_for_phase()) + self.action_mask = [ + agent in acting and self.game_rulebook.agent_states[agent].alive + for agent in self.agents + ] + + async def astep( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> tuple[ + dict[str, Observation], + dict[str, float], + dict[str, bool], + dict[str, bool], + dict[str, dict[Any, Any]], + ]: + assert self.game_rulebook is not None + self._apply_action_mask() + self.turn_number += 1 + prepared = self._coerce_actions(actions) + self.recv_message( + "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") + ) + for agent, action in prepared.items(): + self.recv_message(agent, action) + phase_name = self.game_rulebook.current_phase + events, advance, winner = self.game_rulebook.process_actions(prepared) + exit_events = self.game_rulebook.collect_pending_events() + events.extend(exit_events) + self._record_phase_history( + phase_name=phase_name, + actions=prepared, + events=events, + ) + self._last_events = events + if advance: + next_events = self.game_rulebook.start_next_phase() + self._record_phase_history( + phase_name=self.game_rulebook.current_phase, + actions={}, + events=next_events, + ) + self._last_events.extend(next_events) + self._apply_action_mask() + baseline = self._create_blank_observations() + observations = self._augment_observations(baseline, append_to_existing=False) + rewards = {agent_name: 0 for agent_name in self.agents} + terminated = {agent_name: bool(winner) for agent_name in self.agents} + truncations = {agent_name: False for agent_name in self.agents} + info = { + agent_name: { + "comments": winner["message"] if winner else "", + "complete_rating": 0, + } + for agent_name in self.agents + } + if winner: + self._winner_payload = winner + return observations, rewards, terminated, truncations, info + + def _coerce_actions( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> dict[str, AgentAction]: + prepared: dict[str, AgentAction] = {} + for agent, raw in actions.items(): + if isinstance(raw, AgentAction): + prepared[agent] = raw + else: + idx = int(raw.get("action_type", 0)) + action_type = self.available_action_types[idx] + prepared[agent] = AgentAction( + action_type=action_type, + argument=str(raw.get("argument", "")), + ) + return prepared + + def step( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> tuple[ + dict[str, Observation], + dict[str, float], + dict[str, bool], + dict[str, bool], + dict[str, dict[Any, Any]], + ]: + return asyncio.run(self.astep(actions)) diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index d519eee0d..bcc308ead 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -65,8 +65,20 @@ def sample( env_profile = random.choice(self.env_candidates) if isinstance(env_profile, str): env_profile = EnvironmentProfile.get(env_profile) - logger.info("Creating ParallelSotopiaEnv with %s agents", n_agent) - env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) + logger.info("Creating environment with %s agents", n_agent) + game_meta = getattr(env_profile, "game_metadata", None) or {} + if game_meta.get("mode") == "social_game": + from sotopia.envs import SocialGameEnv + + env = SocialGameEnv( + env_profile=env_profile, + rulebook_path=game_meta["rulebook_path"], + actions_path=game_meta["actions_path"], + role_assignments=game_meta["role_assignments"], + **env_params, + ) + else: + env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) agent_profile_candidates = self.agent_candidates if len(agent_profile_candidates) == n_agent: From 8b8850d9d9b265bb2f981a6743150ab120044ce9 Mon Sep 17 00:00:00 2001 From: "Keyu(Frank) He" Date: Sun, 21 Sep 2025 01:27:53 -0400 Subject: [PATCH 02/21] werewolf game in progress contain minor bugs, will fix in future iterations --- examples/experimental/werewolves/main.py | 4 ++-- sotopia/envs/social_game.py | 12 +++++++----- sotopia/samplers/uniform_sampler.py | 1 + uv.lock | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index 50b9422c6..e5f4dd062 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -6,7 +6,7 @@ import json import os from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, cast import redis @@ -41,7 +41,7 @@ def load_json(path: Path) -> Dict[str, Any]: - return json.loads(path.read_text()) + return cast(Dict[str, Any], json.loads(path.read_text())) def ensure_agent(player: Dict[str, Any]) -> AgentProfile: diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index 926e4ffc8..a0b2b29bc 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -6,11 +6,13 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterable, Optional, Sequence +from typing import Any, Iterable, Optional, Sequence, cast from pydantic import BaseModel, Field, RootModel, ValidationError from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent +from sotopia.agents.llm_agent import Agents +from sotopia.database import EnvironmentProfile from sotopia.messages import AgentAction, Observation, SimpleMessage @@ -395,7 +397,7 @@ def _resolve_phase( raise ValueError( f"Unsupported resolution operation '{phase.resolution.operation}'" ) - return handler(phase, actions, phase.resolution) + return cast(PhaseEvents, handler(phase, actions, phase.resolution)) def _resolve_noop( self, @@ -642,7 +644,7 @@ class SocialGameEnv(ParallelSotopiaEnv): def __init__( self, - env_profile, + env_profile: EnvironmentProfile, *, rulebook_path: str, actions_path: str, @@ -680,7 +682,7 @@ def reset( self, seed: int | None = None, options: dict[str, str] | None = None, - agents=None, + agents: Agents | None = None, omniscient: bool = False, lite: bool = False, ) -> dict[str, Observation]: @@ -891,7 +893,7 @@ async def astep( self._apply_action_mask() baseline = self._create_blank_observations() observations = self._augment_observations(baseline, append_to_existing=False) - rewards = {agent_name: 0 for agent_name in self.agents} + rewards = {agent_name: 0.0 for agent_name in self.agents} terminated = {agent_name: bool(winner) for agent_name in self.agents} truncations = {agent_name: False for agent_name in self.agents} info = { diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index bcc308ead..38d1585f7 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -67,6 +67,7 @@ def sample( env_profile = EnvironmentProfile.get(env_profile) logger.info("Creating environment with %s agents", n_agent) game_meta = getattr(env_profile, "game_metadata", None) or {} + env: ParallelSotopiaEnv if game_meta.get("mode") == "social_game": from sotopia.envs import SocialGameEnv diff --git a/uv.lock b/uv.lock index a0d147290..2e38d6dd9 100644 --- a/uv.lock +++ b/uv.lock @@ -3163,7 +3163,7 @@ requires-dist = [ { name = "google-generativeai", marker = "extra == 'google-generativeai'" }, { name = "groq", marker = "extra == 'groq'" }, { name = "hiredis", specifier = ">=3.0.0" }, - { name = "json-repair", specifier = ">=0.35.0,<0.45.0" }, + { name = "json-repair", specifier = ">=0.35.0,<0.49.0" }, { name = "litellm", specifier = ">=1.65.0" }, { name = "lxml", specifier = ">=4.9.3,<6.0.0" }, { name = "modal", marker = "extra == 'api'" }, From 2cc3990dcaaf4392644c078decbc63a6c9dc4b5b Mon Sep 17 00:00:00 2001 From: Keyu He Date: Fri, 31 Oct 2025 17:49:58 -0400 Subject: [PATCH 03/21] updated prompt --- .../experimental/werewolves/game_rules.json | 11 +++--- examples/experimental/werewolves/main.py | 35 ++++++++++-------- examples/experimental/werewolves/roster.json | 2 +- sotopia/cli/install/redis-data/dump.rdb | Bin 7547 -> 91774 bytes 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/examples/experimental/werewolves/game_rules.json b/examples/experimental/werewolves/game_rules.json index 02a602519..9036b49cb 100644 --- a/examples/experimental/werewolves/game_rules.json +++ b/examples/experimental/werewolves/game_rules.json @@ -120,8 +120,8 @@ "Witch", "Werewolf" ], - "max_cycles": 2, - "max_turns": 12, + "max_cycles": 1, + "max_turns": null, "speech_visibility": "public", "action_visibility": "public", "resolution": { @@ -135,7 +135,7 @@ ], "group": "day", "instructions": [ - "Each villager speaks in turn. Share concise reasoning tied to observations." + "Each villager speaks once in turn. Share concise reasoning tied to observations." ], "role_instructions": {} }, @@ -156,14 +156,15 @@ "visibility": "public" }, "entry_messages": [ - "Voting phase: use 'vote NAME' or 'vote none'." + "Voting phase: respond with action 'vote NAME' or 'vote none'. Do not speak." ], + "max_turns": 1, "exit_messages": [ "Votes are tallied." ], "group": "day", "instructions": [ - "Voting phase: respond with action 'vote NAME' or 'vote none'." + "Voting phase: respond with action 'vote NAME' or 'vote none'. Do not speak." ], "role_instructions": {} }, diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index e5f4dd062..b32ad6750 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -35,7 +35,7 @@ COMMON_GUIDANCE = ( "During your turn you must respond. If 'action' is available, use commands like 'kill NAME', " - "'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Werewolf night speech is private to the pack. " + "'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. " "Day discussion is public. Voting requires an 'action' beginning with 'vote'." ) @@ -68,10 +68,16 @@ def ensure_agent(player: Dict[str, Any]) -> AgentProfile: return profile -def build_agent_goal(player: Dict[str, Any], role_prompt: str) -> str: +def build_agent_goal(player: Dict[str, Any], role_name: str, role_prompt: str) -> str: + # Build role description based on actual role + if role_name == "Villager": + role_desc = f"You are {player['first_name']} {player['last_name']}, a Villager." + else: + role_desc = f"You are {player['first_name']} {player['last_name']}. Your true role is {role_name}. Other players see you as a villager." + return ( - f"You are {player['first_name']} {player['last_name']}, publicly known only as a villager.\n" - f"Primary directives: {player['goal']}\n" + f"{role_desc}" + f"{player['goal']}" f"Role guidance: {role_prompt}\n" f"System constraints: {COMMON_GUIDANCE}" ) @@ -91,13 +97,10 @@ def prepare_scenario() -> tuple[EnvironmentProfile, List[AgentProfile], Dict[str full_name = f"{player['first_name']} {player['last_name']}" role = player["role"] role_prompt = role_actions["roles"][role]["goal_prompt"] - agent_goals.append(build_agent_goal(player, role_prompt)) + agent_goals.append(build_agent_goal(player, role, role_prompt)) role_assignments[full_name] = role - scenario_text = ( - roster["scenario"] - + " Werewolves must be eliminated before they achieve parity with villagers." - ) + scenario_text = roster["scenario"] env_profile = EnvironmentProfile( scenario=scenario_text, @@ -215,14 +218,14 @@ def print_roster(role_assignments: Dict[str, str]) -> None: async def main() -> None: env_profile, agent_profiles, role_assignments = prepare_scenario() - env_model = "gpt-4o-mini" + env_model = "gpt-5" agent_model_list = [ - "gpt-4o-mini", - "gpt-4o-mini", - "gpt-4o-mini", - "gpt-4o-mini", - "gpt-4o-mini", - "gpt-4o-mini", + "gpt-5", + "gpt-5", + "gpt-5", + "gpt-5", + "gpt-5", + "gpt-5", ] env = build_environment(env_profile, role_assignments, env_model) diff --git a/examples/experimental/werewolves/roster.json b/examples/experimental/werewolves/roster.json index 10aa70e57..6abb27ea3 100644 --- a/examples/experimental/werewolves/roster.json +++ b/examples/experimental/werewolves/roster.json @@ -1,5 +1,5 @@ { - "scenario": "In Duskmire, six villagers gather each night to expose the hidden werewolves among them before the pack reaches equal numbers.", + "scenario": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players openly discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. STRATEGY: Villagers must use logic, observation, and deduction to identify Werewolves through their behavior and voting patterns. Werewolves must deceive others, blend in as Villagers, and create confusion. Special roles (Seer, Witch) should use their powers strategically without revealing themselves too early. Trust is earned through consistent behavior and alignment of words with actions.", "players": [ { "first_name": "Aurora", diff --git a/sotopia/cli/install/redis-data/dump.rdb b/sotopia/cli/install/redis-data/dump.rdb index a69d20f841145abfc518a49357728da130f5a366..f2b5cd251d4af68bbe9bd86ebb39e66937b668f2 100644 GIT binary patch literal 91774 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 V-G|{g4#41n1vf6ZVtL#1{~vdxG)Djc delta 349 zcmW;Fy)Oe{9Ki9rN3XadqF!P!sKHB$=V_mNTpRS-8W=4m%iZ;PILRTd!iK~q67m}^ z5{Z96B2E1P)SyHf5)lhsZAM8YUe|9v$@l9{`Ai;k55{Wh8qYc2u;t{v3!|pkdB^3Y z?)Az-tzq5cxnY;PrG~P0m|2lkcn?Pkj|CaFx}wE5G=z%fpi>8J3Woj8KH$m=R8b@B z2B(TdeQpl4NYXewq+_t%sz4z-Dif%x{V-pNLvL~kj3W<8lhD)D1*uTPcc!l>=druTmSrCMhHpLcYDX>Ry1?@^aJkmcyj;% From 22ae860babeb5dc2d979585cfc67f634297cf83c Mon Sep 17 00:00:00 2001 From: Keyu He Date: Thu, 13 Nov 2025 13:33:29 -0500 Subject: [PATCH 04/21] Fix custom/local model support bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes several bugs preventing custom models (via custom/model@url format) from working: - Fix parameter name in generate.py: api_base → base_url (line 257) - Fix hardcoded "gpt-4" evaluator models in server.py (lines 309, 401) Now uses model_dict.get("evaluator", model_dict["env"]) - Add markdown code block stripping in PydanticOutputParser Many local LLMs wrap JSON in ```json...```, parser now handles this - Fix format_bad_output to support custom models Passes base_url/api_key through error recovery path Conditionally uses response_format (custom servers may not support it) --- sotopia/generation_utils/generate.py | 33 ++++++++++++++++++---- sotopia/generation_utils/output_parsers.py | 11 ++++++++ sotopia/server.py | 5 ++-- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index 5abce7ace..ebd91ce29 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -79,6 +79,8 @@ async def format_bad_output( format_instructions: str, model_name: str, use_fixed_model_version: bool = True, + base_url: str | None = None, + api_key: str | None = None, ) -> str: template = """ Given the string that can not be parsed by json parser, reformat it to a string that can be parsed by json parser. @@ -94,13 +96,26 @@ async def format_bad_output( "format_instructions": format_instructions, } content = template.format(**input_values) - response = await acompletion( - model=model_name, - response_format={"type": "json_object"}, - messages=[{"role": "user", "content": content}], - ) + + # Build completion kwargs + completion_kwargs = { + "model": model_name, + "messages": [{"role": "user", "content": content}], + } + + # Only add response_format if not using custom base_url + # Custom servers may not support this parameter + if base_url is None: + completion_kwargs["response_format"] = {"type": "json_object"} + else: + completion_kwargs["base_url"] = base_url + completion_kwargs["api_key"] = api_key + + response = await acompletion(**completion_kwargs) reformatted_output = response.choices[0].message.content assert isinstance(reformatted_output, str) + log.debug(f"Model: {model_name}") + log.debug(f"Prompt: {content}") log.info(f"Reformated output: {reformatted_output}") return reformatted_output @@ -240,6 +255,8 @@ async def _call_with_retry(completion_kwargs: dict[str, Any]) -> Any: # Include agent name in logs if available agent_name = input_values.get("agent", "") log_prefix = f" [{agent_name}]" if agent_name else "" + log.debug(f"Model: {model_name}") + log.debug(f"Prompt: {messages}") log.info(f"Generated result{log_prefix}: {result}") assert isinstance(result, str) return cast(OutputType, output_parser.parse(result)) @@ -250,7 +267,7 @@ async def _call_with_retry(completion_kwargs: dict[str, Any]) -> Any: model=model_name, messages=messages, drop_params=True, - api_base=base_url, + base_url=base_url, api_key=api_key, ) response = await _call_with_retry(completion_kwargs) @@ -271,12 +288,16 @@ async def _call_with_retry(completion_kwargs: dict[str, Any]) -> Any: output_parser.get_format_instructions(), bad_output_process_model or model_name, use_fixed_model_version, + base_url=base_url, + api_key=api_key, ) parsed_result = output_parser.parse(reformat_result) # Include agent name in logs if available agent_name = input_values.get("agent", "") log_prefix = f" [{agent_name}]" if agent_name else "" + log.debug(f"Model: {model_name}") + log.debug(f"Prompt: {messages}") log.info(f"Generated result{log_prefix}: {parsed_result}") return parsed_result diff --git a/sotopia/generation_utils/output_parsers.py b/sotopia/generation_utils/output_parsers.py index 86addcc85..22d170c34 100644 --- a/sotopia/generation_utils/output_parsers.py +++ b/sotopia/generation_utils/output_parsers.py @@ -28,6 +28,17 @@ class PydanticOutputParser(OutputParser[T], Generic[T]): pydantic_object: Type[T] def parse(self, result: str) -> T: + # Strip markdown code blocks if present + result = result.strip() + if result.startswith("```"): + # Remove opening ```json or ``` and closing ``` + lines = result.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] # Remove first line with ``` + if lines and lines[-1].strip() == "```": + lines = lines[:-1] # Remove last line with ``` + result = "\n".join(lines) + json_result = json_repair.loads(result) assert isinstance(json_result, dict) if "properties" in json_result: diff --git a/sotopia/server.py b/sotopia/server.py index 844e98794..d3710f280 100644 --- a/sotopia/server.py +++ b/sotopia/server.py @@ -174,7 +174,6 @@ async def generate_messages() -> ( else: pass - # actions = cast(list[AgentAction], actions) for idx, agent_name in enumerate(env.agents): agent_messages[agent_name] = actions[idx] @@ -307,7 +306,7 @@ def get_agent_class( ], "terminal_evaluators": [ EpisodeLLMEvaluator( - model_dict["env"], + model_dict.get("evaluator", model_dict["env"]), EvaluationForAgents[SotopiaDimensions], ), ], @@ -399,7 +398,7 @@ async def arun_one_script( agent_messages = env_message + agent_messages evaluator: EpisodeLLMEvaluator[SotopiaDimensions] = EpisodeLLMEvaluator( - model_name="gpt-4", + model_name=model_dict.get("evaluator", model_dict["env"]), response_format_class=EvaluationForAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( From 3a9f68978ecd731875ae2eeaa978193aec2600f8 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Thu, 13 Nov 2025 14:16:22 -0500 Subject: [PATCH 05/21] current progress --- ...gotiationArena_1_Buy_Sell_custom_models.py | 209 +++ .../experimental/werewolves/callgraph.dot | 0 examples/experimental/werewolves/config.json | 65 + examples/experimental/werewolves/main.py | 214 ++- myuses.dot | 0 sotopia/envs/action_processor.py | 117 ++ sotopia/envs/social_game.py | 1171 +++++------------ sotopia/envs/social_game_legacy.py | 464 +++++++ sotopia/envs/social_game_legacy2.py | 656 +++++++++ sotopia/samplers/uniform_sampler.py | 10 +- 10 files changed, 1902 insertions(+), 1004 deletions(-) create mode 100644 examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py create mode 100644 examples/experimental/werewolves/callgraph.dot create mode 100644 examples/experimental/werewolves/config.json create mode 100644 myuses.dot create mode 100644 sotopia/envs/action_processor.py create mode 100644 sotopia/envs/social_game_legacy.py create mode 100644 sotopia/envs/social_game_legacy2.py diff --git a/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py b/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py new file mode 100644 index 000000000..d9f6b81bc --- /dev/null +++ b/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py @@ -0,0 +1,209 @@ +# Run this in terminal: redis-stack-server --dir ~/Sotopia/examples/experimental/negotiation_arena/redis-data +import redis +import os +import asyncio +from typing import Any, cast +from sotopia.database.persistent_profile import AgentProfile, EnvironmentProfile +from sotopia.samplers import UniformSampler +from sotopia.server import run_async_server +from sotopia.messages import Observation, AgentAction +from constants import ( + AGENT_ONE, + AGENT_TWO, + MONEY_TOKEN, + RESOURCES_TAG, + GOALS_TAG, + PLAYER_ANSWER_TAG, + PROPOSED_TRADE_TAG, + ACCEPTING_TAG, + REJECTION_TAG, + MESSAGE_TAG, + PROPOSAL_COUNT_TAG, + REASONING_TAG, +) + + +client = redis.Redis(host="localhost", port=6379) + +os.environ["REDIS_OM_URL"] = "redis://:@localhost:6379" + + +def add_agent_to_database(**kwargs: Any) -> None: + agent = AgentProfile(**kwargs) + agent.save() + + +def add_env_profile(**kwargs: Any) -> None: + env_profile = EnvironmentProfile(**kwargs) + env_profile.save() + + +try: + alice = cast( + AgentProfile, + AgentProfile.find( + AgentProfile.first_name == "Red", AgentProfile.last_name == "Player" + ).all()[0], + ) +except (IndexError, NotImplementedError): + print("Agent not found, creating new agent profiles.") + add_agent_to_database( + first_name="Red", + last_name="Player", + age=30, + occupation="", + gender="", + gender_pronoun="", + big_five="", + moral_values=[], + decision_making_style="", + secret="", + ) + alice = cast( + AgentProfile, + AgentProfile.find( + AgentProfile.first_name == "Red", AgentProfile.last_name == "Player" + ).all()[0], + ) + +try: + bob = cast( + AgentProfile, + AgentProfile.find( + AgentProfile.first_name == "Blue", AgentProfile.last_name == "Player" + ).all()[0], + ) +except (IndexError, NotImplementedError): + print("Agent not found, creating new agent profiles.") + add_agent_to_database( + first_name="Blue", + last_name="Player", + age=30, + occupation="", + gender="", + gender_pronoun="", + big_five="", + moral_values=[], + decision_making_style="", + secret="", + ) + bob = cast( + AgentProfile, + AgentProfile.find( + AgentProfile.first_name == "Blue", AgentProfile.last_name == "Player" + ).all()[0], + ) + +scenario = "Player RED is going to sell one object. Player BLUE gives ZUP to buy resources." # @param {type:"string"} + + +def buy_sell_prompt( + resources_available_in_game: str, + starting_initial_resources: str, + player_goal: str, + maximum_number_of_proposals: int, + player_social_behaviour: str, +) -> str: + prompt = f"""You are playing game where you are buying or selling an object. There is only one object for sale/purcahse. + +{AGENT_ONE} is going to sell one object. {AGENT_TWO} gives {MONEY_TOKEN} to buy resources. + +RULES: + +``` +1. You must always respond with: + + A) Propose a trade with (you can only trade in integer amounts, not decimals): + <{PLAYER_ANSWER_TAG}> PROPOSAL + <{PROPOSED_TRADE_TAG}> {AGENT_ONE} Gives {resources_available_in_game}: amount, ...| {AGENT_TWO} Gives {MONEY_TOKEN}: amount + + B) Accept the trade by saying: + <{PLAYER_ANSWER_TAG}> {ACCEPTING_TAG} + <{PROPOSED_TRADE_TAG}> NONE + + C) Reject and end the game: + <{PLAYER_ANSWER_TAG}> {REJECTION_TAG} + <{PROPOSED_TRADE_TAG}> NONE + + Note: The game will end if one of the players {ACCEPTING_TAG} OR {REJECTION_TAG}. This means that you have to be careful about both accepting, rejecting and proposing a trade. + +2. You are allowed at most {maximum_number_of_proposals} proposals of your own to complete the game, after which you can only reply with {ACCEPTING_TAG} or {REJECTION_TAG}. +DO NOT propose a new trade after {maximum_number_of_proposals} proposals. Your limit for proposals is {maximum_number_of_proposals}. + +3. At each turn send messages to each other by using the following format: + +<{MESSAGE_TAG}>your message here + +You can decide if you want disclose your resources, goals, cost and willingness to pay in the message. +``` + +Here is what you have access to: +``` +Object that is being bought/sold: {resources_available_in_game} +<{RESOURCES_TAG}> {starting_initial_resources} +<{GOALS_TAG}> {player_goal} , +``` + +All the responses you send should contain the following and in this order: + +``` +<{PROPOSAL_COUNT_TAG}> [add here (inclusive of current)] +<{RESOURCES_TAG}> [add here] +<{GOALS_TAG}> [add here] +<{REASONING_TAG}> [add here] +<{PLAYER_ANSWER_TAG}> [add here] +<{PROPOSED_TRADE_TAG}> [add here] +<{MESSAGE_TAG}> [add here] None: + await run_async_server( + model_dict={ + "env": "custom/google/gemma-3-27b@http://127.0.0.1:1234/v1", + "agent1": "custom/qwen/qwen3-1.7b@http://127.0.0.1:1234/v1", + "agent2": "custom/qwen/qwen3-1.7b@http://127.0.0.1:1234/v1", + }, + sampler=sampler, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/experimental/werewolves/callgraph.dot b/examples/experimental/werewolves/callgraph.dot new file mode 100644 index 000000000..e69de29bb diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json new file mode 100644 index 000000000..541326b59 --- /dev/null +++ b/examples/experimental/werewolves/config.json @@ -0,0 +1,65 @@ +{ + "scenario": "Werewolves Game", + "description": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players openly discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. STRATEGY: Villagers must use logic, observation, and deduction to identify Werewolves through their behavior and voting patterns. Werewolves must deceive others, blend in as Villagers, and create confusion. Special roles (Seer, Witch) should use their powers strategically without revealing themselves too early. Trust is earned through consistent behavior and alignment of words with actions.\n During your turn you must respond. If 'action' is available, use commands like 'kill NAME', 'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Day discussion is public. Voting requires an 'action' beginning with 'vote'." , + "role_goals": { + "Villager": "Act openly and collaboratively to identify werewolves.", + "Werewolf": "Deceive others, avoid detection, and eliminate villagers.", + "Seer": "Discreetly inspect players at night to uncover werewolves.", + "Witch": "Use save/poison potions strategically to aid villagers." + }, + "role_secrets": { + "Villager": "", + "Werewolf": "I am a werewolf.", + "Seer": "", + "Witch": "" + }, + "agents": [ + { "name": "Aurora", "role": "Villager" , "team": "Villagers"}, + { "name": "Bram", "role": "Werewolf" , "team": "Werewolves"}, + { "name": "Celeste", "role": "Seer" , "team": "Villagers"}, + { "name": "Dorian", "role": "Werewolf" , "team": "Werewolves"}, + { "name": "Elise", "role": "Witch" , "team": "Villagers"}, + { "name": "Finn", "role": "Villager" , "team": "Villagers"} + ], + "initial_state": "Night_werewolf", + "state_transition": { + "Night_werewolf": "Night_seer", + "Night_seer": "Night_witch", + "Night_witch": "Day_discussion", + "Day_discussion": "Day_vote", + "Day_vote": "Night_werewolf" + }, + "state_properties": { + "Night_werewolf": { + "acting_roles": ["Werewolf"], + "actions": ["speak", "action"], + "visibility": "team" + }, + "Night_seer": { + "acting_roles": ["Seer"], + "actions": ["action"], + "visibility": "private" + }, + "Night_witch": { + "acting_roles": ["Witch"], + "actions": ["action"], + "visibility": "private", + "internal_state": { + "save_available": true, + "poison_available": true + } + }, + "Day_discussion": { + "actions": ["speak"], + "visibility": "public" + }, + "Day_vote": { + "actions": ["action"], + "visibility": { "during": "private", "after": "public" } + } + }, + "end_conditions": [ + { "type": "team_eliminated", "team": "Werewolves", "winner": "Villagers", "message": "[Game] Villagers win; no werewolves remain." }, + { "type": "parity", "team": "Werewolves", "other": "Villagers", "winner": "Werewolves", "message": "[Game] Werewolves win; they now match the village." } + ] +} diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index b32ad6750..0e9bfb162 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -6,7 +6,8 @@ import json import os from pathlib import Path -from typing import Any, Dict, List, cast +import logging +from typing import Any, Dict, List, cast, Tuple import redis @@ -26,91 +27,104 @@ from sotopia.database import SotopiaDimensions BASE_DIR = Path(__file__).resolve().parent -ROLE_ACTIONS_PATH = BASE_DIR / "role_actions.json" -RULEBOOK_PATH = BASE_DIR / "game_rules.json" -ROSTER_PATH = BASE_DIR / "roster.json" +CONFIG_PATH = BASE_DIR / "config.json" os.environ.setdefault("REDIS_OM_URL", "redis://:@localhost:6379") redis.Redis(host="localhost", port=6379) -COMMON_GUIDANCE = ( - "During your turn you must respond. If 'action' is available, use commands like 'kill NAME', " - "'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. " - "Day discussion is public. Voting requires an 'action' beginning with 'vote'." +# Configure debug file logging for generation traces +LOG_FILE = BASE_DIR / "werewolves_game_debug.log" +# _fh is the file handler, which is used to log the >=DEBUG levels to a .log file. +_fh = logging.FileHandler(LOG_FILE, mode="w", encoding="utf-8") +_fh.setFormatter( + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s - %(message)s") ) +_gen_logger = logging.getLogger("sotopia.generation") +_gen_logger.setLevel(logging.DEBUG) +_gen_logger.addHandler(_fh) def load_json(path: Path) -> Dict[str, Any]: return cast(Dict[str, Any], json.loads(path.read_text())) +def split_name(full_name: str) -> Tuple[str, str]: + first_name, last_name = ( + full_name.split(" ", 1) if " " in full_name else (full_name, "") + ) + return first_name, last_name + + +# for god roles (seer, witch), their roles can be revealed to other players + + def ensure_agent(player: Dict[str, Any]) -> AgentProfile: try: - profile = AgentProfile.find( - AgentProfile.first_name == player["first_name"], - AgentProfile.last_name == player["last_name"], - ).all()[0] - return profile # type: ignore[return-value] - except IndexError: - profile = AgentProfile( - first_name=player["first_name"], - last_name=player["last_name"], - age=player.get("age", 30), - occupation="", - gender="", - gender_pronoun=player.get("pronouns", "they/them"), - public_info="", - personality_and_values="", - decision_making_style="", - secret=player.get("secret", ""), - ) - profile.save() - return profile - - -def build_agent_goal(player: Dict[str, Any], role_name: str, role_prompt: str) -> str: - # Build role description based on actual role - if role_name == "Villager": - role_desc = f"You are {player['first_name']} {player['last_name']}, a Villager." - else: - role_desc = f"You are {player['first_name']} {player['last_name']}. Your true role is {role_name}. Other players see you as a villager." - - return ( - f"{role_desc}" - f"{player['goal']}" - f"Role guidance: {role_prompt}\n" - f"System constraints: {COMMON_GUIDANCE}" + existing = AgentProfile.find( + (AgentProfile.first_name == player["first_name"]) + & (AgentProfile.last_name == player["last_name"]) # combine predicates + ).all() + except Exception: + existing = [] + if existing: + prof = AgentProfile.get(existing[0].pk) + return prof + + profile = AgentProfile( + first_name=player["first_name"], + last_name=player["last_name"], + age=player.get("age", ""), + occupation=player.get("occupation", ""), + gender=player.get("gender", ""), + gender_pronoun=player.get("pronouns", ""), + public_info=player.get("public_info", ""), + personality_and_values=player.get("personality_and_values", ""), + decision_making_style=player.get("decision_making_style", ""), + secret=player.get("secret", ""), ) + profile.save() + return profile def prepare_scenario() -> tuple[EnvironmentProfile, List[AgentProfile], Dict[str, str]]: - role_actions = load_json(ROLE_ACTIONS_PATH) - roster = load_json(ROSTER_PATH) - + assert CONFIG_PATH.exists(), f"config.json not found at {CONFIG_PATH}" + cfg = load_json(CONFIG_PATH) agents: List[AgentProfile] = [] - agent_goals: List[str] = [] role_assignments: Dict[str, str] = {} - for player in roster["players"]: + for entry in cfg.get("agents", []): + full_name = cast(str, entry.get("name", "Unknown Name")) + role = cast(str, entry.get("role", "Unknown Role")) + first_name, last_name = split_name(full_name) + role_goal = cfg.get("role_goals", {}).get(role, "") + role_secret = cfg.get("role_secrets", {}).get(role, "") + # Build a complete player payload for profile creation/update + player: Dict[str, Any] = { + "first_name": first_name, + "last_name": last_name, + "pronouns": entry.get("pronouns", ""), + "age": entry.get("age", ""), + "gender": entry.get("gender", ""), + "occupation": entry.get("occupation", ""), + "public_info": entry.get("public_info", ""), + "personality_and_values": entry.get("personality_and_values", ""), + "decision_making_style": entry.get("decision_making_style", ""), + "goal": role_goal, + "secret": role_secret, + } profile = ensure_agent(player) agents.append(profile) - full_name = f"{player['first_name']} {player['last_name']}" - role = player["role"] - role_prompt = role_actions["roles"][role]["goal_prompt"] - agent_goals.append(build_agent_goal(player, role, role_prompt)) role_assignments[full_name] = role - scenario_text = roster["scenario"] - + scenario_text = cast( + str, cfg.get("description") or cfg.get("scenario") or "Werewolves game" + ) env_profile = EnvironmentProfile( scenario=scenario_text, - agent_goals=agent_goals, relationship=RelationshipType.acquaintance, game_metadata={ "mode": "social_game", - "rulebook_path": str(RULEBOOK_PATH), - "actions_path": str(ROLE_ACTIONS_PATH), - "role_assignments": role_assignments, + "config_path": str(CONFIG_PATH), }, tag="werewolves", ) @@ -125,9 +139,7 @@ def build_environment( ) -> SocialGameEnv: return SocialGameEnv( env_profile=env_profile, - rulebook_path=str(RULEBOOK_PATH), - actions_path=str(ROLE_ACTIONS_PATH), - role_assignments=role_assignments, + config_path=str(CONFIG_PATH), model_name=model_name, action_order="round-robin", evaluators=[RuleBasedTerminatedEvaluator(max_turn_number=40, max_stale_turn=2)], @@ -144,72 +156,25 @@ def create_agents( agent_profiles: List[AgentProfile], env_profile: EnvironmentProfile, model_names: List[str], + default_model: str, ) -> List[LLMAgent]: + cfg = load_json(CONFIG_PATH) + cfg_agents = cfg.get("agents", []) agents: List[LLMAgent] = [] - for profile, model_name, goal in zip( - agent_profiles, - model_names, - env_profile.agent_goals, - strict=True, - ): + for idx, profile in enumerate(agent_profiles): + # priority: explicit model_names list > per-agent config override > default_model + if idx < len(model_names) and model_names[idx]: + model_name = model_names[idx] + elif idx < len(cfg_agents) and cfg_agents[idx].get("model"): + model_name = cast(str, cfg_agents[idx]["model"]) + else: + model_name = default_model agent = LLMAgent(agent_profile=profile, model_name=model_name) - agent.goal = goal + agent.goal = env_profile.agent_goals[idx] agents.append(agent) return agents -def summarize_phase_log(phase_log: List[Dict[str, Any]]) -> None: - if not phase_log: - print("\nNo structured events recorded.") - return - - print("\nTimeline by Phase") - print("=" * 60) - - last_label: str | None = None - for entry in phase_log: - phase_name = entry["phase"] - meta = entry.get("meta", {}) - group = meta.get("group") - cycle = meta.get("group_cycle") - stage = meta.get("group_stage") - title = phase_name.replace("_", " ").title() - if group: - group_label = group.replace("_", " ").title() - if cycle and stage: - label = f"{group_label} {cycle}.{stage} – {title}" - elif cycle: - label = f"{group_label} {cycle} – {title}" - else: - label = f"{group_label}: {title}" - else: - label = title - - if label != last_label: - print(f"\n[{label}]") - last_label = label - instructions = entry.get("instructions", []) - for info_line in instructions: - print(f" Info: {info_line}") - role_instr = entry.get("role_instructions", {}) - for role, lines in role_instr.items(): - for line in lines: - print(f" Role {role}: {line}") - - for msg in entry.get("public", []): - print(f" Public: {msg}") - for team, messages in entry.get("team", {}).items(): - for msg in messages: - print(f" Team ({team}) private: {msg}") - for agent, messages in entry.get("private", {}).items(): - for msg in messages: - print(f" Private to {agent}: {msg}") - for actor, action in entry.get("actions", {}).items(): - print( - f" Action logged: {actor} -> {action['action_type']} {action['argument']}" - ) - - def print_roster(role_assignments: Dict[str, str]) -> None: print("Participants & roles:") for name, role in role_assignments.items(): @@ -229,9 +194,9 @@ async def main() -> None: ] env = build_environment(env_profile, role_assignments, env_model) - agents = create_agents(agent_profiles, env_profile, agent_model_list) + agents = create_agents(agent_profiles, env_profile, agent_model_list, env_model) - print("🌕 Duskmire Werewolves — Structured Social Game") + print("🌕 Duskmire Werewolves") print("=" * 60) print_roster(role_assignments) print("=" * 60) @@ -246,13 +211,6 @@ async def main() -> None: push_to_db=False, ) - summarize_phase_log(env.phase_log) - - if env._winner_payload: # noqa: SLF001 (internal inspection for demo) - print("\nGame Result:") - print(f"Winner: {env._winner_payload['winner']}") - print(f"Reason: {env._winner_payload['message']}") - if __name__ == "__main__": asyncio.run(main()) diff --git a/myuses.dot b/myuses.dot new file mode 100644 index 000000000..e69de29bb diff --git a/sotopia/envs/action_processor.py b/sotopia/envs/action_processor.py new file mode 100644 index 000000000..514dc2f68 --- /dev/null +++ b/sotopia/envs/action_processor.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import itertools +import random +from typing import Any, Dict, Tuple + +from sotopia.messages import AgentAction, Observation, SimpleMessage +from sotopia.envs.evaluators import unweighted_aggregate_evaluate +from sotopia.envs.parallel import render_text_for_agent, _actions_to_natural_language + + +class PlainActionProcessor: + """Stateless processor that turns raw actions into observations using base env semantics. + + This class expects an `env` object with attributes/methods used below (ParallelSotopiaEnv-compatible): + - agents, available_action_types, action_mask, action_order, evaluators, inbox, + recv_message, turn_number + """ + + def process( + self, + env: Any, + actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], + ) -> Tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + # 1) Compile actions to AgentAction + complied_actions: Dict[str, AgentAction] = {} + for key, raw in actions.items(): + if isinstance(raw, AgentAction): + complied_actions[key] = raw + else: + raw["action_type"] = env.available_action_types[int(raw["action_type"])] + complied_actions[key] = AgentAction.parse_obj(raw) + + # 2) Apply action mask - non-turn agents are forced to none + for idx, agent in enumerate(env.agents): + if not env.action_mask[idx]: + complied_actions[agent] = AgentAction(action_type="none", argument="") + + # 3) Record messages + env.recv_message( + "Environment", SimpleMessage(message=f"Turn #{env.turn_number}") + ) + for agent, action in complied_actions.items(): + env.recv_message(agent, action) + + # 4) Evaluate turn + response = unweighted_aggregate_evaluate( + list( + itertools.chain( + *( + evaluator(turn_number=env.turn_number, messages=env.inbox) + for evaluator in env.evaluators + ) + ) + ) + ) + + # 5) Next-turn action mask policy + env.action_mask = [False for _ in env.agents] + if env.action_order == "round-robin": + env.action_mask[env.turn_number % len(env.action_mask)] = True + elif env.action_order == "random": + env.action_mask[random.randint(0, len(env.action_mask) - 1)] = True + else: + env.action_mask = [True for _ in env.agents] + + # 6) Build observations + obs_text = _actions_to_natural_language(complied_actions) + observations: Dict[str, Observation] = {} + for i, agent_name in enumerate(env.agents): + observations[agent_name] = Observation( + last_turn=render_text_for_agent(obs_text, agent_id=i), + turn_number=env.turn_number, + available_actions=list(env.available_action_types) + if env.action_mask[i] + else ["none"], + ) + + # 7) Rewards/termination/truncation/info + rewards = {agent_name: 0.0 for agent_name in env.agents} + terminated = {agent_name: response.terminated for agent_name in env.agents} + truncations = {agent_name: False for agent_name in env.agents} + info = { + agent_name: {"comments": response.comments or "", "complete_rating": 0} + for agent_name in env.agents + } + return observations, rewards, terminated, truncations, info + + +class SocialGameActionProcessor(PlainActionProcessor): + """Extension point for social game state machines. + + Override/extend hooks to implement per-state masking, visibility, and transitions. + """ + + def process( + self, + env: Any, + actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], + ) -> Tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + # Optionally apply pre-processing (e.g., state-based masking) here + result = super().process(env, actions) + # Optionally apply post-processing (e.g., state transitions, visibility logs) here + # self._apply_state_transition(env, actions) # implement as needed + return result diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index a0b2b29bc..6fb42ccff 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -1,916 +1,275 @@ -"""Social game environment that reads its rulebook and action space from JSON.""" - from __future__ import annotations import asyncio import json -from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterable, Optional, Sequence, cast - -from pydantic import BaseModel, Field, RootModel, ValidationError +from typing import Any, Dict, List, Literal from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent from sotopia.agents.llm_agent import Agents from sotopia.database import EnvironmentProfile -from sotopia.messages import AgentAction, Observation, SimpleMessage - - -class RoleActionConfig(BaseModel): - """Declared abilities and messaging semantics for a specific role.""" - - name: str - team: str - description: str = "" - goal_prompt: str = "" - default_actions: list[str] = Field(default_factory=lambda: ["speak", "action"]) - phase_actions: dict[str, list[str]] = Field(default_factory=dict) - initial_state: dict[str, Any] = Field(default_factory=dict) - allow_team_private_speech: bool = False - allow_role_private_speech: bool = False - - -class RoleActionLibrary(RootModel[dict[str, RoleActionConfig]]): - """Pydantic wrapper for mapping roles to role metadata.""" - - def team_for_role(self, role: str) -> str: - return self.root[role].team - - -class PhaseResolution(BaseModel): - operation: str = Field( - default="noop", - description="Name of the builtin resolution handler to invoke at phase end.", - ) - state_key: str | None = None - visibility: str = Field( - default="public", - description="Default visibility for resolution feedback.", - ) - - -class PhaseDefinition(BaseModel): - name: str - kind: str = Field( - default="discussion", - description="Macro describing how the phase behaves (discussion, team_target, vote, single_target, announcement).", - ) - group: str | None = Field( - default=None, - description="Optional label used to cluster phases into higher-level cycles (e.g., 'night', 'day').", - ) - turn_mode: str = Field( - default="round-robin", - description="round-robin => sequential actors, simultaneous => everyone at once, single => one actor only.", - ) - acting_roles: list[str] | None = None - acting_teams: list[str] | None = None - max_cycles: int = Field( - default=1, - description="Number of complete round-robin passes required before the phase advances.", - ) - max_turns: int | None = Field( - default=None, - description="Optional cap on total turns inside the phase (overrides max_cycles when smaller).", - ) - speech_visibility: str = Field( - default="public", - description="Where speech is visible ('public', 'team', 'private', 'hidden').", - ) - action_visibility: str = Field( - default="public", - description="Where action outcomes are visible ('public', 'team', 'private', 'hidden').", - ) - instructions: list[str] = Field( - default_factory=list, - description="General prompts injected into agent observations for this phase.", - ) - role_instructions: dict[str, list[str]] = Field( - default_factory=dict, - description="Optional role-specific prompts keyed by role name.", - ) - resolution: PhaseResolution | None = None - entry_messages: list[str] = Field(default_factory=list) - exit_messages: list[str] = Field(default_factory=list) - description: str = "" - - -class EndConditionDefinition(BaseModel): - operation: str - team: str | None = None - other_team: str | None = None - winner: str | None = None - message: str | None = None - - -class RulebookConfig(BaseModel): - initial_phase: str - phases: list[PhaseDefinition] - phase_transitions: dict[str, str] - end_conditions: list[EndConditionDefinition] = Field(default_factory=list) - max_cycles: int | None = Field( - default=None, - description="Optional safety bound on day/night cycles to prevent infinite games.", - ) - - -@dataclass -class AgentState: - name: str - role: str - team: str - alive: bool = True - attributes: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class PhaseEvents: - public: list[str] = field(default_factory=list) - team: dict[str, list[str]] = field(default_factory=dict) - private: dict[str, list[str]] = field(default_factory=dict) - system: list[str] = field(default_factory=list) - - def extend(self, other: "PhaseEvents") -> None: - self.public.extend(other.public) - for team, messages in other.team.items(): - self.team.setdefault(team, []).extend(messages) - for agent, messages in other.private.items(): - self.private.setdefault(agent, []).extend(messages) - self.system.extend(other.system) - - @classmethod - def phase_entry(cls, phase_name: str, messages: list[str]) -> "PhaseEvents": - events = cls() - for msg in messages: - events.public.append(f"[God] Phase '{phase_name}' begins: {msg}") - if not messages: - events.public.append(f"[God] Phase '{phase_name}' begins.") - return events - - -class GameRulebook: - """Runtime state machine that enforces the JSON described social game.""" - - def __init__(self, rules: RulebookConfig, roles: RoleActionLibrary) -> None: - self.rules = rules - self.roles = roles - self.phase_lookup = {phase.name: phase for phase in rules.phases} - self.agent_states: dict[str, AgentState] = {} - self.agent_name_lookup: dict[str, str] = {} - self.current_phase: str = rules.initial_phase - self.phase_cycle_progress: int = 0 - self.turns_in_phase: int = 0 - self.current_actor_index: int = 0 - self.state_flags: dict[str, Any] = {} - self.group_cycle: dict[str, int] = {} - self.group_stage: dict[str, int] = {} - self.current_phase_meta: dict[str, Any] = {} - self.pending_events: PhaseEvents = PhaseEvents() - - # ------------------------------------------------------------------ - # Initialisation - # ------------------------------------------------------------------ - def assign_agents( - self, - agents: Sequence[str], - role_assignments: dict[str, str], - ) -> None: - self.agent_states = {} - self.agent_name_lookup = {} - for name in agents: - role = role_assignments[name] - role_cfg = self.roles.root.get(role) - if role_cfg is None: - raise ValueError(f"Unknown role '{role}' for agent '{name}'") - attrs = dict(role_cfg.initial_state) - state = AgentState( - name=name, - role=role, - team=role_cfg.team, - alive=True, - attributes=attrs, - ) - self.agent_states[name] = state - self.agent_name_lookup[name.lower()] = name - self.agent_name_lookup[name.split()[0].lower()] = name - - self.current_phase = self.rules.initial_phase - self.phase_cycle_progress = 0 - self.turns_in_phase = 0 - self.current_actor_index = 0 - self.state_flags = { - "day_execution": None, - "night_target": None, - "witch_saved": None, - "witch_poisoned": None, - "seer_result": "", - } - self.group_cycle.clear() - self.group_stage.clear() - self.current_phase_meta = {} - self._register_phase_entry(self.current_phase) - entry_phase = self.phase_lookup[self.current_phase] - self.pending_events = PhaseEvents.phase_entry( - self.current_phase, entry_phase.entry_messages - ) +from sotopia.messages import AgentAction, Observation, Message, SimpleMessage - # ------------------------------------------------------------------ - # Accessors used by the environment - # ------------------------------------------------------------------ - def alive_agents(self) -> list[str]: - return [name for name, state in self.agent_states.items() if state.alive] - - def active_agents_for_phase(self) -> list[str]: - phase = self.phase_lookup[self.current_phase] - eligible = self._eligible_candidates(phase) - if not eligible: - return [] - if phase.turn_mode == "round-robin": - idx = self.current_actor_index - if idx >= len(eligible): - idx = len(eligible) - 1 - if idx < 0: - idx = 0 - return [eligible[idx]] - return eligible - - def available_actions(self, agent_name: str) -> list[str]: - agent_state = self.agent_states[agent_name] - if not agent_state.alive: - return ["none"] - role_cfg = self.roles.root[agent_state.role] - actions = role_cfg.phase_actions.get( - self.current_phase, role_cfg.default_actions - ) - if "none" not in actions: - actions = list(actions) + ["none"] - return actions - - def collect_pending_events(self) -> PhaseEvents: - events = self.pending_events - self.pending_events = PhaseEvents() - return events - - # ------------------------------------------------------------------ - # Core update logic - # ------------------------------------------------------------------ - def process_actions( - self, actions: dict[str, AgentAction] - ) -> tuple[PhaseEvents, bool, Optional[dict[str, str]]]: - phase = self.phase_lookup[self.current_phase] - acting_agents = self.active_agents_for_phase() - events = PhaseEvents() - - if phase.kind == "announcement": - events.extend(self._resolve_phase(phase, {})) - winner = self._check_end_conditions() - self._schedule_phase_exit(phase) - return events, True, winner - - if not acting_agents: - events.extend(self._resolve_phase(phase, {})) - winner = self._check_end_conditions() - self._schedule_phase_exit(phase) - return events, True, winner - - relevant = { - name: actions.get(name, AgentAction(action_type="none", argument="")) - for name in acting_agents - } - if phase.turn_mode == "round-robin": - actor = acting_agents[0] - events.extend(self._record_speech(actor, relevant[actor], phase)) - events.extend(self._resolve_phase(phase, {actor: relevant[actor]})) - self._advance_round_robin(phase) - advance = self._should_advance(phase) - else: - for actor, action in relevant.items(): - events.extend(self._record_speech(actor, action, phase)) - events.extend(self._resolve_phase(phase, relevant)) - advance = True - - winner = self._check_end_conditions() - if winner: - self._schedule_phase_exit(phase) - return events, True, winner - - if advance: - self._schedule_phase_exit(phase) - return events, advance, winner - - def start_next_phase(self) -> PhaseEvents: - next_phase = self.rules.phase_transitions.get(self.current_phase) - if next_phase is None: - raise ValueError( - f"No transition defined after phase '{self.current_phase}'" - ) - self.current_phase = next_phase - self.phase_cycle_progress = 0 - self.turns_in_phase = 0 - self.current_actor_index = 0 - self._register_phase_entry(next_phase) - phase_def = self.phase_lookup[next_phase] - entry = PhaseEvents.phase_entry(next_phase, phase_def.entry_messages) - return entry - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - def _phase_group(self, phase: PhaseDefinition) -> str: - if phase.group: - return phase.group - return phase.name - - def _register_phase_entry(self, phase_name: str) -> None: - phase = self.phase_lookup[phase_name] - group = self._phase_group(phase) - previous_group = ( - self.current_phase_meta.get("group") if self.current_phase_meta else None - ) - cycle = self.group_cycle.get(group, 0) - stage = self.group_stage.get(group, 0) - if previous_group != group: - cycle += 1 - stage = 1 - else: - stage += 1 - self.group_cycle[group] = cycle - self.group_stage[group] = stage - self.current_phase_meta = { - "phase": phase_name, - "group": group, - "group_cycle": cycle, - "group_stage": stage, - "display_name": phase.name.replace("_", " ").title(), - } +class GameMessage(Message): + """A message object with explicit recipients. - def current_phase_metadata(self) -> dict[str, Any]: - return dict(self.current_phase_meta) if self.current_phase_meta else {} - - def _eligible_candidates(self, phase: PhaseDefinition) -> list[str]: - names = [name for name, state in self.agent_states.items() if state.alive] - if phase.acting_roles: - names = [ - name - for name in names - if self.agent_states[name].role in phase.acting_roles - ] - if phase.acting_teams: - names = [ - name - for name in names - if self.agent_states[name].team in phase.acting_teams - ] - return names - - def _record_speech( - self, actor: str, action: AgentAction, phase: PhaseDefinition - ) -> PhaseEvents: - events = PhaseEvents() - if action.action_type not in {"speak", "non-verbal communication"}: - return events - utterance = action.argument.strip() - if not utterance: - return events - line = f'{actor} said: "{utterance}"' - if phase.speech_visibility == "team": - team = self.agent_states[actor].team - events.team.setdefault(team, []).append(line) - elif phase.speech_visibility == "private": - events.private.setdefault(actor, []).append(line) - elif phase.speech_visibility == "hidden": - pass - else: - events.public.append(line) - return events - - def _resolve_phase( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - ) -> PhaseEvents: - if phase.resolution is None: - return PhaseEvents() - handler = getattr(self, f"_resolve_{phase.resolution.operation}", None) - if handler is None: - raise ValueError( - f"Unsupported resolution operation '{phase.resolution.operation}'" - ) - return cast(PhaseEvents, handler(phase, actions, phase.resolution)) + recipients=None means public; otherwise only listed agents can view. + """ - def _resolve_noop( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - return PhaseEvents() - - def _resolve_store_target( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - target = self._extract_target(actions.values()) - if target: - self.state_flags[resolution.state_key or "night_target"] = target - teams = phase.acting_teams or [self.agent_states[a].team for a in actions] - for team in teams: - events.team.setdefault(team, []).append( - f"[God] Target locked: {target}." - ) - return events + sender: str + content: str + state: str + recipients: list[str] | None = None + kind: Literal["speak", "action", "none"] = "none" - def _resolve_seer_inspect( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - if not actions: - return events - actor, action = next(iter(actions.items())) - target = self._extract_target([action]) - if not target: - events.private.setdefault(actor, []).append( - "[God] Vision failed: unable to interpret your target." - ) - return events - team = self.agent_states[target].team - message = f"[God] Vision reveals {target} serves team {team}." - events.private.setdefault(actor, []).append(message) - self.state_flags["seer_result"] = message - return events - - def _resolve_witch_phase( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - if not actions: - return events - actor, action = next(iter(actions.items())) - state = self.agent_states[actor] - text = action.argument.lower() - if "save" in text and state.attributes.get("save_available", True): - target = self._extract_target([action]) or self.state_flags.get( - "night_target" - ) - if target: - self.state_flags["witch_saved"] = target - state.attributes["save_available"] = False - events.private.setdefault(actor, []).append( - f"[God] You secretly saved {target} tonight." - ) - if "poison" in text and state.attributes.get("poison_available", True): - target = self._extract_target([action]) - if target: - self.state_flags["witch_poisoned"] = target - state.attributes["poison_available"] = False - events.private.setdefault(actor, []).append( - f"[God] You poisoned {target}." - ) - if not text.strip() or "pass" in text: - events.private.setdefault(actor, []).append( - "[God] You chose to remain idle." - ) - return events - - def _resolve_resolve_night( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - saved = self.state_flags.get("witch_saved") - target = self.state_flags.get("night_target") - poison = self.state_flags.get("witch_poisoned") - casualties: list[str] = [] - if target and target != saved: - casualties.append(target) - if poison and poison not in casualties: - casualties.append(poison) - if not casualties: - events.public.append("[God] Dawn breaks peacefully. No one died.") - for victim in casualties: - if victim in self.agent_states and self.agent_states[victim].alive: - self.agent_states[victim].alive = False - events.public.append(f"[God] {victim} was found dead at dawn.") - self.state_flags["night_target"] = None - self.state_flags["witch_saved"] = None - self.state_flags["witch_poisoned"] = None - self.state_flags["seer_result"] = "" - return events - - def _resolve_vote( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - tally: dict[str, int] = {} - for action in actions.values(): - target = self._extract_target([action]) - if target: - tally[target] = tally.get(target, 0) + 1 - elif "none" in action.argument.lower(): - tally.setdefault("none", 0) - tally["none"] += 1 - if not tally: - events.public.append("[God] No valid votes were cast.") - self.state_flags["day_execution"] = None - return events - winner, votes = max(tally.items(), key=lambda kv: kv[1]) - if winner == "none": - events.public.append("[God] The town decided to stay their hand.") - self.state_flags["day_execution"] = None - return events - if list(tally.values()).count(votes) > 1: - events.public.append("[God] The vote is tied. No execution today.") - self.state_flags["day_execution"] = None - return events - self.state_flags["day_execution"] = winner - events.public.append( - f"[God] Majority condemns {winner}. Execution will happen at twilight." - ) - return events - - def _resolve_post_vote_cleanup( - self, - phase: PhaseDefinition, - actions: dict[str, AgentAction], - resolution: PhaseResolution, - ) -> PhaseEvents: - events = PhaseEvents() - target = self.state_flags.get("day_execution") - if target and target in self.agent_states and self.agent_states[target].alive: - self.agent_states[target].alive = False - team = self.agent_states[target].team - events.public.append( - f"[God] {target} was executed. They belonged to team {team}." - ) - self.state_flags["day_execution"] = None - return events - - def _extract_target(self, actions: Iterable[AgentAction]) -> str | None: - for action in actions: - corpus = f"{action.action_type} {action.argument}".lower() - for name in self.agent_states: - if name.lower() in corpus: - return name - for name in self.agent_states: - first = name.split()[0].lower() - if first in corpus: - return name - return None - - def _advance_round_robin(self, phase: PhaseDefinition) -> None: - base = self._eligible_candidates(phase) - self.turns_in_phase += 1 - if not base: - self.current_actor_index = 0 - return - self.current_actor_index += 1 - if self.current_actor_index >= len(base): - self.phase_cycle_progress += 1 - self.current_actor_index = 0 - - def _should_advance(self, phase: PhaseDefinition) -> bool: - if phase.turn_mode != "round-robin": - return True - base = self._eligible_candidates(phase) - if not base: - return True - if phase.max_turns is not None and self.turns_in_phase >= phase.max_turns: - return True - if self.phase_cycle_progress >= phase.max_cycles: - return True - return False - - def _schedule_phase_exit(self, phase: PhaseDefinition) -> None: - exit_events = PhaseEvents() - for msg in phase.exit_messages: - exit_events.public.append(f"[God] {msg}") - self.pending_events.extend(exit_events) - - def _check_end_conditions(self) -> Optional[dict[str, str]]: - for cond in self.rules.end_conditions: - if cond.operation == "team_eliminated" and cond.team: - alive = sum( - 1 - for state in self.agent_states.values() - if state.alive and state.team == cond.team - ) - if alive == 0: - message = ( - cond.message or f"[God] Team {cond.team} has been eliminated." - ) - return { - "winner": cond.winner or cond.other_team or cond.team, - "message": message, - } - if cond.operation == "parity" and cond.team and cond.other_team: - team_count = sum( - 1 - for state in self.agent_states.values() - if state.alive and state.team == cond.team - ) - other_count = sum( - 1 - for state in self.agent_states.values() - if state.alive and state.team == cond.other_team - ) - if team_count >= other_count: - message = cond.message or ( - f"[God] Parity reached: {cond.team} now matches or exceeds {cond.other_team}." - ) - return { - "winner": cond.winner or cond.team, - "message": message, - } - return None + def to_natural_language(self) -> str: + if self.kind == "speak": + return f'{self.sender} said: "{self.content}"' + elif self.kind == "action": + return f"{self.sender} [action] {self.content}" + elif self.kind == "none": + return f"{self.sender} did nothing" + else: + raise ValueError(f"Invalid message kind: {self.kind}") class SocialGameEnv(ParallelSotopiaEnv): - """Environment subclass that enforces multi-phase social game mechanics.""" + """ + Core concepts: + - Per-state acting roles and action space + - Per-message visibility: public, team, private + - Per-agent message history is derived from the game message log + """ def __init__( self, env_profile: EnvironmentProfile, *, - rulebook_path: str, - actions_path: str, - role_assignments: dict[str, str], + config_path: str, **kwargs: Any, ) -> None: super().__init__(env_profile=env_profile, **kwargs) - self._rulebook_path = Path(rulebook_path) - self._actions_path = Path(actions_path) - self._role_assignments = role_assignments - self.game_rulebook: GameRulebook | None = None - self._last_events: PhaseEvents = PhaseEvents() - self._winner_payload: dict[str, str] | None = None - self.phase_log: list[dict[str, Any]] = [] - - # ------------------------------------------------------------------ - # Config loading helpers - # ------------------------------------------------------------------ - def _load_configs(self) -> tuple[RulebookConfig, RoleActionLibrary]: - try: - rules = RulebookConfig.model_validate_json(self._rulebook_path.read_text()) - except ValidationError as exc: - raise ValueError(f"Invalid rulebook config: {exc}") from exc - actions_raw = json.loads(self._actions_path.read_text()) - try: - roles = RoleActionLibrary.model_validate(actions_raw["roles"]) - except (KeyError, ValidationError) as exc: - raise ValueError(f"Invalid action-space config: {exc}") from exc - return rules, roles - - # ------------------------------------------------------------------ - # Overrides - # ------------------------------------------------------------------ + self._config_path = Path(config_path) + self._config: Dict[str, Any] = {} + + self.role_to_team: Dict[ + str, str + ] = {} # Map roles to teams (e.g. Seer --> Villagers)) + self.agent_to_role: Dict[ + str, str + ] = {} # Map agents to roles (e.g. Aurora --> Villager) + self.agent_alive: Dict[ + str, bool + ] = {} # Map agents to their alive status (e.g. Aurora --> True (alive) or False (dead)) + + self.current_state: str = "" # Current state of the game (e.g. Day_discussion) + self.message_log: List[GameMessage] = [] # Log of all messages sent in the game + self.state_log: List[Dict[str, Any]] = [] # Log of all states in the game + self._state_transition: Dict[ + str, str + ] = {} # Map of state transitions (e.g. Night_witch --> Day_discussion) + self._state_props: Dict[ + str, Dict[str, Any] + ] = {} # Map of state properties (e.g. Night --> {acting_roles: ["Werewolf"], actions: ["speak", "action"], visibility: "team"}) + self.internal_state: Dict[ + str, Any + ] = {} # Internal state of the game (e.g. votes, night_target, witch_save, witch_poison) + + # ----------------------------- + # Config loading + # ----------------------------- + def _load_config(self) -> None: + # Read config and normalize to FSM structures + if not self._config_path.exists(): + raise FileNotFoundError(f"config_path does not exist: {self._config_path}") + self._config = json.loads(self._config_path.read_text()) + + # Build role_to_team from agents if available + self.role_to_team = {} + for agent in self._config.get("agents", []): + role = agent.get("role") + team = agent.get("team") + if isinstance(role, str) and isinstance(team, str): + self.role_to_team.setdefault(role, team) + + # FSM structures + self._state_transition = dict(self._config.get("state_transition", {})) + self._state_props = dict(self._config.get("state_properties", {})) + + # ----------------------------- + # Lifecycle + # ----------------------------- def reset( self, seed: int | None = None, - options: dict[str, str] | None = None, + options: Dict[str, str] | None = None, agents: Agents | None = None, omniscient: bool = False, lite: bool = False, - ) -> dict[str, Observation]: + ) -> Dict[str, Observation]: base_obs = super().reset( - seed=seed, - options=options, - agents=agents, - omniscient=omniscient, - lite=lite, + seed=seed, options=options, agents=agents, omniscient=omniscient, lite=lite ) - rules, role_actions = self._load_configs() - self.game_rulebook = GameRulebook(rules, role_actions) - self.game_rulebook.assign_agents(self.agents, self._role_assignments) - self.phase_log = [] + + self._load_config() + + self.agent_to_role = {} + for name in self.agents: + role = next( + ( + a.get("role", "Villager") + for a in self._config.get("agents", []) + if a.get("name") == name + ), + "Villager", + ) + self.agent_to_role[name] = role + + self.agent_alive = {name: True for name in self.agents} + self.current_state = self._config.get("initial_state", "Night") + self.message_log = [] + self.state_log = [] + self.internal_state = {} + init_spec = self._state_spec(self.current_state) + if isinstance(init_spec.get("internal_state"), dict): + self.internal_state.update(init_spec["internal_state"]) + + # Prepare first observation self._apply_action_mask() - self._last_events = self.game_rulebook.collect_pending_events() - self._winner_payload = None - self._record_phase_history( - phase_name=self.game_rulebook.current_phase, - actions={}, - events=self._last_events, - ) - return self._augment_observations(base_obs, append_to_existing=True) + self._append_system_message(f"[Game] State: {self.current_state}") + return self._build_observations(base_obs, append_to_existing=True) - def _phase_prompt_lines( - self, - *, - agent_name: str, - phase: PhaseDefinition, - acting: bool, - available: list[str], - ) -> list[str]: - assert self.game_rulebook is not None - meta = self.game_rulebook.current_phase_metadata() - group = meta.get("group") - cycle = meta.get("group_cycle") - stage = meta.get("group_stage") - title = phase.name.replace("_", " ").title() - if group: - group_label = group.replace("_", " ").title() - if cycle and stage: - label = f"{group_label} {cycle}.{stage} – {title}" - elif cycle: - label = f"{group_label} {cycle} – {title}" - else: - label = f"{group_label}: {title}" - else: - label = title - lines = [f"[God] Phase: {label}"] - if acting: - lines.append("[God] It is your turn to act in this phase.") - else: - lines.append("[God] You are observing while others act.") - lines.append(f"[God] Available actions right now: {', '.join(available)}") - lines.extend(f"[God] {text}" for text in phase.instructions) - role = self.game_rulebook.agent_states[agent_name].role - for text in phase.role_instructions.get(role, []): - lines.append(f"[God] {text}") - return lines - - def _record_phase_history( - self, - *, - phase_name: str, - actions: dict[str, AgentAction], - events: PhaseEvents, - ) -> None: - if self.game_rulebook is None: + # ----------------------------- + # Core helpers + # ----------------------------- + def _apply_action_mask(self) -> None: + # Determine eligible actors by role and alive status + eligible = [ + n + for n in self.agents + if self.agent_alive.get(n, True) + and ( + not set(self._state_spec(self.current_state).get("acting_roles", [])) + or self.agent_to_role.get(n, "") + in set(self._state_spec(self.current_state).get("acting_roles", [])) + ) + ] + + # Order policy from config: "round-robin" (default) or "simultaneous" + order = str(self._state_spec(self.current_state).get("order", "round-robin")) + if order == "round-robin": + mask = [False for _ in self.agents] + if eligible: + idx = self.turn_number % len(eligible) + current_actor = eligible[idx] + for i, name in enumerate(self.agents): + mask[i] = name == current_actor + self.action_mask = mask return - if not (events.public or events.team or events.private): - if any(a.action_type != "none" for a in actions.values()): - pass - else: - return - action_summary = { - agent: {"action_type": action.action_type, "argument": action.argument} - for agent, action in actions.items() - if action.action_type != "none" - } - phase_def = ( - self.game_rulebook.phase_lookup.get(phase_name) - if self.game_rulebook - else None - ) - snapshot = { - "phase": phase_name, - "turn": self.turn_number, - "public": list(events.public), - "team": {team: list(msgs) for team, msgs in events.team.items()}, - "private": {agent: list(msgs) for agent, msgs in events.private.items()}, - "actions": action_summary, - "meta": self.game_rulebook.current_phase_metadata() - if self.game_rulebook - else {}, - "instructions": phase_def.instructions if phase_def else [], - "role_instructions": phase_def.role_instructions if phase_def else {}, - } - self.phase_log.append(snapshot) - def _augment_observations( - self, - baseline: dict[str, Observation], - *, - append_to_existing: bool, - ) -> dict[str, Observation]: - assert self.game_rulebook is not None - acting = set(self.game_rulebook.active_agents_for_phase()) - events = self._last_events - phase_name = self.game_rulebook.current_phase - phase_def = self.game_rulebook.phase_lookup[phase_name] - new_obs: dict[str, Observation] = {} - for idx, agent_name in enumerate(self.agents): - current = baseline[agent_name] - available = ( - self.game_rulebook.available_actions(agent_name) - if agent_name in acting - else ["none"] + # Default: simultaneous for all eligible actors + eligible_set = set(eligible) + self.action_mask = [name in eligible_set for name in self.agents] + + def _state_spec(self, state: str) -> Dict[str, Any]: + return dict(self._state_props.get(state, {})) + + def _allowed_actions_for_role(self, state: str, role: str) -> List[str]: + spec = self._state_spec(state) + allowed = list(spec.get("actions", ["none"])) + if "none" not in allowed: + allowed.append("none") + acting_roles = spec.get("acting_roles") + if acting_roles and role not in set(acting_roles): + return ["none"] + return allowed + + def available_actions(self, agent_name: str) -> List[str]: + if not self.agent_alive.get(agent_name, True): + return ["none"] + role = self.agent_to_role.get(agent_name, "Villager") + return self._allowed_actions_for_role(self.current_state, role) + + def active_agents_for_state(self) -> List[str]: + acting_roles = set(self._state_spec(self.current_state).get("acting_roles", [])) + return [ + n + for n in self.agents + if self.agent_alive.get(n, True) + and (not acting_roles or self.agent_to_role.get(n, "") in acting_roles) + ] + + def _append_system_message(self, text: str) -> None: + self.message_log.append( + GameMessage( + sender="Environment", + content=text, + state=self.current_state, + recipients=None, ) - phase_lines = self._phase_prompt_lines( - agent_name=agent_name, - phase=phase_def, - acting=agent_name in acting, - available=available, + ) + + def _can_view(self, agent_name: str, m: GameMessage) -> bool: + return m.recipients is None or agent_name in (m.recipients or []) + + def _visible_text(self, agent_name: str) -> str: + return "\n".join( + m.to_natural_language() + for m in self.message_log + if self._can_view(agent_name, m) + ) + + def _build_observations( + self, baseline: Dict[str, Observation], *, append_to_existing: bool + ) -> Dict[str, Observation]: + acting = set(self.active_agents_for_state()) + new_obs: Dict[str, Observation] = {} + for idx, name in enumerate(self.agents): + current = baseline[name] + available = self.available_actions(name) if name in acting else ["none"] + + lines: List[str] = [] + if append_to_existing and current.last_turn.strip(): + lines.append(current.last_turn.strip()) + lines.append(f"[Game] State: {self.current_state}") + lines.append( + "[Game] It is your turn to act." + if name in acting + else "[Game] You are observing this state." ) - messages: list[str] = [] - messages.extend(events.public) - team = self.game_rulebook.agent_states[agent_name].team - messages.extend(events.team.get(team, [])) - messages.extend(events.private.get(agent_name, [])) - if not messages: - messages.append("[God] Await instructions from the host.") - segments: list[str] = [] - if append_to_existing: - prefix = current.last_turn.strip() - if prefix: - segments.append(prefix) - segments.extend(phase_lines) - segments.extend(messages) - combined = "\n".join(segment for segment in segments if segment) - new_obs[agent_name] = Observation( + lines.append(f"[Game] Available actions: {', '.join(available)}") + visible = self._visible_text(name) + if visible: + lines.append(visible) + else: + lines.append("[Game] Await instructions from the host.") + + combined = "\n".join(seg for seg in lines if seg) + new_obs[name] = Observation( last_turn=render_text_for_agent(combined, agent_id=idx), turn_number=current.turn_number, available_actions=available, ) return new_obs - def _create_blank_observations(self) -> dict[str, Observation]: - assert self.game_rulebook is not None - acting = set(self.game_rulebook.active_agents_for_phase()) - blank: dict[str, Observation] = {} - for agent_name in self.agents: - available = ( - self.game_rulebook.available_actions(agent_name) - if agent_name in acting - else ["none"] - ) - blank[agent_name] = Observation( - last_turn="", - turn_number=self.turn_number, - available_actions=available, - ) - return blank - - def _apply_action_mask(self) -> None: - assert self.game_rulebook is not None - acting = set(self.game_rulebook.active_agents_for_phase()) - self.action_mask = [ - agent in acting and self.game_rulebook.agent_states[agent].alive - for agent in self.agents - ] - + # ----------------------------- + # Step + # ----------------------------- async def astep( - self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] ) -> tuple[ - dict[str, Observation], - dict[str, float], - dict[str, bool], - dict[str, bool], - dict[str, dict[Any, Any]], + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], ]: - assert self.game_rulebook is not None self._apply_action_mask() self.turn_number += 1 - prepared = self._coerce_actions(actions) - self.recv_message( - "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") - ) - for agent, action in prepared.items(): - self.recv_message(agent, action) - phase_name = self.game_rulebook.current_phase - events, advance, winner = self.game_rulebook.process_actions(prepared) - exit_events = self.game_rulebook.collect_pending_events() - events.extend(exit_events) - self._record_phase_history( - phase_name=phase_name, - actions=prepared, - events=events, - ) - self._last_events = events - if advance: - next_events = self.game_rulebook.start_next_phase() - self._record_phase_history( - phase_name=self.game_rulebook.current_phase, - actions={}, - events=next_events, - ) - self._last_events.extend(next_events) - self._apply_action_mask() - baseline = self._create_blank_observations() - observations = self._augment_observations(baseline, append_to_existing=False) - rewards = {agent_name: 0.0 for agent_name in self.agents} - terminated = {agent_name: bool(winner) for agent_name in self.agents} - truncations = {agent_name: False for agent_name in self.agents} - info = { - agent_name: { - "comments": winner["message"] if winner else "", - "complete_rating": 0, - } - for agent_name in self.agents - } - if winner: - self._winner_payload = winner - return observations, rewards, terminated, truncations, info - def _coerce_actions( - self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] - ) -> dict[str, AgentAction]: - prepared: dict[str, AgentAction] = {} + # Normalize actions + prepared: Dict[str, AgentAction] = {} for agent, raw in actions.items(): if isinstance(raw, AgentAction): prepared[agent] = raw @@ -918,18 +277,88 @@ def _coerce_actions( idx = int(raw.get("action_type", 0)) action_type = self.available_action_types[idx] prepared[agent] = AgentAction( - action_type=action_type, - argument=str(raw.get("argument", "")), + action_type=action_type, argument=str(raw.get("argument", "")) + ) + + self.recv_message( + "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") + ) + for agent, action in prepared.items(): + self.recv_message(agent, action) + + acting = set(self.active_agents_for_state()) + recorded_msgs: List[GameMessage] = [] + + # Record messages based on visibility rules + for actor in acting: + act = prepared.get(actor) + if not act or act.action_type == "none": + continue + if act.action_type == "speak": + gm = GameMessage( + sender=actor, + content=act.argument.strip(), + state=self.current_state, + recipients=None, # default public; make explicit via config if needed + kind="speak", ) - return prepared + if gm.content: + self.message_log.append(gm) + recorded_msgs.append(gm) + elif act.action_type == "action": + gm = GameMessage( + sender=actor, + content=act.argument.strip(), + state=self.current_state, + recipients=None, # default public; make explicit via config if needed + kind="action", + ) + if gm.content: + self.message_log.append(gm) + recorded_msgs.append(gm) + + # State advancement + self.current_state = self._state_transition.get( + self.current_state, self.current_state + ) + state_internal = self._state_spec(self.current_state).get("internal_state") + if isinstance(state_internal, dict): + self.internal_state.update(state_internal) + self._append_system_message(f"[Game] State: {self.current_state}") + + # Append to state_log for external summarization if needed + self.state_log.append( + { + "state": self.current_state, + "public": [ + m.to_natural_language() + for m in self.message_log + if m.recipients is None and m.state == self.current_state + ], + } + ) + + self._apply_action_mask() + baseline = { + name: Observation( + last_turn="", turn_number=self.turn_number, available_actions=["none"] + ) + for name in self.agents + } + observations = self._build_observations(baseline, append_to_existing=False) + rewards = {a: 0.0 for a in self.agents} + terminated = {a: False for a in self.agents} + truncations = {a: False for a in self.agents} + info = {a: {"comments": "", "complete_rating": 0} for a in self.agents} + return observations, rewards, terminated, truncations, info def step( - self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] ) -> tuple[ - dict[str, Observation], - dict[str, float], - dict[str, bool], - dict[str, bool], - dict[str, dict[Any, Any]], + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], ]: return asyncio.run(self.astep(actions)) diff --git a/sotopia/envs/social_game_legacy.py b/sotopia/envs/social_game_legacy.py new file mode 100644 index 000000000..584b39b61 --- /dev/null +++ b/sotopia/envs/social_game_legacy.py @@ -0,0 +1,464 @@ +"""Minimal social game environment for phase-based multi-agent games like Werewolf.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from sotopia.agents.llm_agent import Agents +from sotopia.database import EnvironmentProfile +from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent +from sotopia.messages import AgentAction, Observation, SimpleMessage + +__all__ = [ + "GameRules", + "SocialGame", + "SocialGameEnv", + "RoleConfig", + "PhaseConfig", + "GameState", +] + + +# ============================================================================ +# Configuration Models +# ============================================================================ + + +class RoleConfig(BaseModel): + """Role definition: team, actions per phase.""" + + team: str + actions: dict[str, list[str]] # phase -> allowed actions + goal: str = "" + + +class PhaseConfig(BaseModel): + """Phase definition: who acts, visibility, resolution.""" + + actors: list[str] | None = None # roles that act (None = all alive) + visibility: str = "public" # public|team|private + resolution: str = "none" # none|kill|vote|inspect|witch|announce_deaths + next_phase: str = "" + + +class GameRules(BaseModel): + """Complete game configuration.""" + + roles: dict[str, RoleConfig] + phases: dict[str, PhaseConfig] + start_phase: str + win_conditions: list[dict[str, str]] # [{type: "eliminate", team: "Werewolves"}] + + +# ============================================================================ +# Game State +# ============================================================================ + + +@dataclass +class GameState: + """Runtime game state.""" + + agents: dict[str, dict[str, Any]] = field( + default_factory=dict + ) # name -> {role, team, alive, ...} + phase: str = "" + state: dict[str, Any] = field( + default_factory=dict + ) # shared state (targets, votes, etc) + messages: list[str] = field(default_factory=list) # pending public messages + team_messages: dict[str, list[str]] = field( + default_factory=dict + ) # team -> messages + private_messages: dict[str, list[str]] = field( + default_factory=dict + ) # agent -> messages + + def alive(self) -> list[str]: + return [name for name, info in self.agents.items() if info["alive"]] + + def by_team(self, team: str) -> list[str]: + return [ + name + for name, info in self.agents.items() + if info["team"] == team and info["alive"] + ] + + def add_msg( + self, msg: str, visibility: str = "public", target: str | None = None + ) -> None: + """Add message with visibility control.""" + if visibility == "public": + self.messages.append(msg) + elif visibility == "team" and target: + self.team_messages.setdefault(target, []).append(msg) + elif visibility == "private" and target: + self.private_messages.setdefault(target, []).append(msg) + + def flush_messages( + self, + ) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]: + """Return and clear all messages.""" + pub, team, priv = ( + self.messages[:], + dict(self.team_messages), + dict(self.private_messages), + ) + self.messages.clear() + self.team_messages.clear() + self.private_messages.clear() + return pub, team, priv + + +# ============================================================================ +# Game Engine +# ============================================================================ + + +class SocialGame: + """Phase-based social game engine.""" + + def __init__(self, rules: GameRules): + self.rules = rules + self.state = GameState() + + def init(self, agents: list[str], roles: dict[str, str]) -> None: + """Initialize game with agent-role assignments.""" + self.state.agents = { + name: { + "role": roles[name], + "team": self.rules.roles[roles[name]].team, + "alive": True, + "attrs": {}, # role-specific state + } + for name in agents + } + self.state.phase = self.rules.start_phase + self.state.state = { + "votes": {}, + "night_target": None, + "witch_save": False, + "witch_poison": None, + } + # Witch tracking + for name, info in self.state.agents.items(): + if info["role"] == "Witch": + info["attrs"]["save_used"] = False + info["attrs"]["poison_used"] = False + + def active_agents(self) -> list[str]: + """Who can act in current phase?""" + phase_cfg = self.rules.phases[self.state.phase] + if not phase_cfg.actors: + return self.state.alive() + return [ + n + for n in self.state.alive() + if self.state.agents[n]["role"] in phase_cfg.actors + ] + + def available_actions(self, agent: str) -> list[str]: + """What actions can agent take?""" + if not self.state.agents[agent]["alive"]: + return ["none"] + role = self.state.agents[agent]["role"] + phase = self.state.phase + return self.rules.roles[role].actions.get(phase, ["none"]) + + def process_turn( + self, actions: dict[str, AgentAction] + ) -> tuple[bool, dict[str, str] | None]: + """Process actions, run resolution, return (phase_done, winner).""" + phase_cfg = self.rules.phases[self.state.phase] + + # Record speech + for agent, action in actions.items(): + if ( + action.action_type in ["speak", "non-verbal communication"] + and action.argument.strip() + ): + msg = f"{agent}: {action.argument}" + vis = phase_cfg.visibility + target = ( + self.state.agents[agent]["team"] + if vis == "team" + else agent + if vis == "private" + else None + ) + self.state.add_msg(msg, vis, target) + + # Resolve phase + self._resolve(phase_cfg.resolution, actions) + + # Check win + winner = self._check_win() + + # Advance phase + self.state.phase = phase_cfg.next_phase + + return True, winner + + def _resolve(self, resolution: str, actions: dict[str, AgentAction]) -> None: + """Execute phase resolution logic.""" + if resolution == "none": + return + + if resolution == "kill": + # Werewolves pick target + target = self._extract_name(actions) + if target: + self.state.state["night_target"] = target + team = self.state.agents[list(actions.keys())[0]]["team"] + self.state.add_msg(f"Target: {target}", "team", team) + + elif resolution == "inspect": + # Seer inspects + target = self._extract_name(actions) + if target and actions: + agent = list(actions.keys())[0] + team = self.state.agents[target]["team"] + self.state.add_msg(f"{target} is on team {team}", "private", agent) + + elif resolution == "witch": + # Witch save/poison + if not actions: + return + agent = list(actions.keys())[0] + arg = list(actions.values())[0].argument.lower() + + if "save" in arg and not self.state.agents[agent]["attrs"]["save_used"]: + target = self.state.state.get("night_target") + if target: + self.state.state["witch_save"] = True + self.state.agents[agent]["attrs"]["save_used"] = True + self.state.add_msg(f"You saved {target}", "private", agent) + + if "poison" in arg and not self.state.agents[agent]["attrs"]["poison_used"]: + target = self._extract_name(actions) + if target: + self.state.state["witch_poison"] = target + self.state.agents[agent]["attrs"]["poison_used"] = True + self.state.add_msg(f"You poisoned {target}", "private", agent) + + elif resolution == "announce_deaths": + # Resolve night kills + killed = [] + target = self.state.state.get("night_target") + if target and not self.state.state.get("witch_save"): + killed.append(target) + poison = self.state.state.get("witch_poison") + if poison and poison not in killed: + killed.append(poison) + + if killed: + for victim in killed: + self.state.agents[victim]["alive"] = False + self.state.add_msg(f"{victim} died") + else: + self.state.add_msg("No one died") + + # Reset night state + self.state.state.update( + {"night_target": None, "witch_save": False, "witch_poison": None} + ) + + elif resolution == "vote": + # Tally votes + votes: dict[str, int] = {} + for action in actions.values(): + target = self._extract_name({0: action}) + if target: + votes[target] = votes.get(target, 0) + 1 + + if votes: + winner = max(votes, key=votes.get) # type: ignore + max_votes = votes[winner] + # Check tie + if list(votes.values()).count(max_votes) == 1: + self.state.agents[winner]["alive"] = False + team = self.state.agents[winner]["team"] + self.state.add_msg(f"Voted out: {winner} (team {team})") + else: + self.state.add_msg("Vote tied, no execution") + else: + self.state.add_msg("No valid votes") + + def _extract_name(self, actions: dict[Any, AgentAction]) -> str | None: + """Extract target name from action arguments.""" + for action in actions.values(): + text = f"{action.action_type} {action.argument}".lower() + for name in self.state.agents: + if name.lower() in text or name.split()[0].lower() in text: + return name + return None + + def _check_win(self) -> dict[str, str] | None: + """Check win conditions.""" + for cond in self.rules.win_conditions: + if cond["type"] == "eliminate": + team = cond["team"] + if not self.state.by_team(team): + return { + "winner": cond.get("winner", "Other"), + "message": f"Team {team} eliminated", + } + elif cond["type"] == "parity": + team1 = self.state.by_team(cond["team"]) + team2 = self.state.by_team(cond["other"]) + if len(team1) >= len(team2): + return { + "winner": cond["team"], + "message": f"{cond['team']} reached parity", + } + return None + + +# ============================================================================ +# Environment Wrapper +# ============================================================================ + + +class SocialGameEnv(ParallelSotopiaEnv): + """Sotopia environment for social games.""" + + def __init__( + self, + env_profile: EnvironmentProfile, + *, + rules_path: str, + role_assignments: dict[str, str], + **kwargs: Any, + ): + super().__init__(env_profile=env_profile, **kwargs) + self.rules_path = Path(rules_path) + self.role_assignments = role_assignments + self.game: SocialGame | None = None + + def reset( + self, + seed: int | None = None, + options: dict[str, str] | None = None, + agents: Agents | None = None, + omniscient: bool = False, + lite: bool = False, + ) -> dict[str, Observation]: + obs = super().reset(seed, options, agents, omniscient, lite) + + # Load rules and init game + rules = GameRules.model_validate_json(self.rules_path.read_text()) + self.game = SocialGame(rules) + self.game.init(self.agents, self.role_assignments) + + return self._build_observations(obs) + + def _build_observations( + self, base_obs: dict[str, Observation] + ) -> dict[str, Observation]: + """Build observations with game state.""" + assert self.game is not None + + pub, team, priv = self.game.state.flush_messages() + active = set(self.game.active_agents()) + + new_obs = {} + for idx, agent in enumerate(self.agents): + actions = self.game.available_actions(agent) + + # Collect visible messages + msgs = pub[:] + agent_team = self.game.state.agents[agent]["team"] + msgs.extend(team.get(agent_team, [])) + msgs.extend(priv.get(agent, [])) + + # Build prompt + role = self.game.state.agents[agent]["role"] + phase = self.game.state.phase + lines = [ + f"Phase: {phase}", + f"Role: {role}", + f"Actions: {', '.join(actions)}", + f"Active: {'Yes' if agent in active else 'No'}", + "", + *msgs, + ] + + new_obs[agent] = Observation( + last_turn=render_text_for_agent("\n".join(lines), idx), + turn_number=self.turn_number, + available_actions=actions, + ) + + return new_obs + + async def astep( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> tuple[ + dict[str, Observation], + dict[str, float], + dict[str, bool], + dict[str, bool], + dict[str, dict[Any, Any]], + ]: + assert self.game is not None + + self.turn_number += 1 + + # Convert actions + converted = {} + for agent, action in actions.items(): + if isinstance(action, AgentAction): + converted[agent] = action + else: + act_type = self.available_action_types[ + int(action.get("action_type", 0)) + ] + converted[agent] = AgentAction( + action_type=act_type, argument=str(action.get("argument", "")) + ) + + # Log + self.recv_message( + "Environment", SimpleMessage(message=f"Turn {self.turn_number}") + ) + for agent, action in converted.items(): + self.recv_message(agent, action) + + # Process + _, winner = self.game.process_turn(converted) + + # Build observations + base_obs = { + agent: Observation( + last_turn="", turn_number=self.turn_number, available_actions=["none"] + ) + for agent in self.agents + } + obs = self._build_observations(base_obs) + + # Results + rewards = {a: 0.0 for a in self.agents} + terminated = {a: bool(winner) for a in self.agents} + truncated = {a: False for a in self.agents} + info = { + a: {"comments": winner["message"] if winner else "", "complete_rating": 0} + for a in self.agents + } + + return obs, rewards, terminated, truncated, info + + def step( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> tuple[ + dict[str, Observation], + dict[str, float], + dict[str, bool], + dict[str, bool], + dict[str, dict[Any, Any]], + ]: + return asyncio.run(self.astep(actions)) diff --git a/sotopia/envs/social_game_legacy2.py b/sotopia/envs/social_game_legacy2.py new file mode 100644 index 000000000..d2652bc1a --- /dev/null +++ b/sotopia/envs/social_game_legacy2.py @@ -0,0 +1,656 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent +from sotopia.agents.llm_agent import Agents +from sotopia.database import EnvironmentProfile +from sotopia.messages import AgentAction, Observation, SimpleMessage + + +# ----------------------------- +# Data containers +# ----------------------------- + + +@dataclass +class AgentState: + """Runtime state for an agent: identity, role/team, alive flag and extras.""" + + name: str + role: str + team: str + alive: bool = True + attributes: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Events: + """Aggregated event streams for a phase step (public/team/private).""" + + public: List[str] = field(default_factory=list) + team: Dict[str, List[str]] = field(default_factory=dict) + private: Dict[str, List[str]] = field(default_factory=dict) + + def extend(self, other: "Events") -> None: + self.public.extend(other.public) + for k, v in other.team.items(): + self.team.setdefault(k, []).extend(v) + for k, v in other.private.items(): + self.private.setdefault(k, []).extend(v) + + +@dataclass +class SimpleRules: + """Parsed rulebook config. + + example JSON shape: + { + "initial_phase": "night", + "next_phase": {"night": "day", "day": "night"}, + "phases": { + "night": { + "acting_roles": ["Werewolf", "Seer", "Witch"], + "speech_visibility": {"Werewolf": "team", "Seer": "private", "Witch": "private"}, + "resolvers": [ {"op": "store_target", ...}, ... ] + }, + "day": { ... } + }, + "end_conditions": [ ... ] + } + """ + + initial_phase: str + next_phase: Dict[str, str] + phases: Dict[str, Dict[str, Any]] + end_conditions: List[Dict[str, Any]] + + +@dataclass +class SimpleActions: + """Parsed action-space config. + + example JSON shape: + { + "teams": {"Villager": "Villagers", "Werewolf": "Werewolves", "Seer": "Villagers",...}, + "phase_actions": {"night": {"Werewolf": ["speak","action"], "Villager": ["none"]}, "day": {"*": ["speak","action"]}}, + "initial_state": {"Witch": {"save_available": true, "poison_available": true}} + } + """ + + role_to_team: Dict[str, str] + phase_actions: Dict[str, Dict[str, List[str]]] + initial_state: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + +# ----------------------------- +# Environment +# ----------------------------- + + +class SocialGameEnv(ParallelSotopiaEnv): + """Social game environment + - Speech routing is driven by per-phase visibility rules + - Available actions come from phase x role mapping + - End-of-phase effects are executed by generic, JSON-declared resolvers + """ + + def __init__( + self, + env_profile: EnvironmentProfile, + *, + rulebook_path: str, + actions_path: str, + role_assignments: Dict[str, str], + **kwargs: Any, + ) -> None: + super().__init__(env_profile=env_profile, **kwargs) + self._rulebook_path = Path(rulebook_path) + self._actions_path = Path(actions_path) + self._role_assignments = role_assignments + + self.rules: Optional[SimpleRules] = None + self.actions: Optional[SimpleActions] = None + self.agent_states: Dict[str, AgentState] = {} + self.current_phase: str = "" + self._last_events: Events = Events() + self._winner_payload: Optional[Dict[str, str]] = None + + # Ephemeral named values for cross-resolver coordination + self.state_flags: Dict[str, Any] = {} + + # ----------------------------- + # Config loading + # ----------------------------- + def _load_configs(self) -> None: + rules_raw = json.loads(self._rulebook_path.read_text()) + actions_raw = json.loads(self._actions_path.read_text()) + + phases = rules_raw.get("phases") or {} + self.rules = SimpleRules( + initial_phase=rules_raw.get("initial_phase", ""), + next_phase=rules_raw.get("next_phase", {}), + phases=phases, + end_conditions=rules_raw.get("end_conditions", []), + ) + + self.actions = SimpleActions( + role_to_team=actions_raw.get("teams", {}), + phase_actions=actions_raw.get("phase_actions", {}), + initial_state=actions_raw.get("initial_state", {}), + ) + + # ----------------------------- + # Lifecycle + # ----------------------------- + def reset( + self, + seed: int | None = None, + options: Dict[str, str] | None = None, + agents: Agents | None = None, + omniscient: bool = False, + lite: bool = False, + ) -> Dict[str, Observation]: + """Reset the environment""" + base_obs = super().reset( + seed=seed, options=options, agents=agents, omniscient=omniscient, lite=lite + ) + + self._load_configs() + assert self.rules is not None and self.actions is not None + + # Assign agents + self.agent_states.clear() + for name in self.agents: + role = self._role_for_agent(name) + team = self.actions.role_to_team.get(role, "") + attrs = dict(self.actions.initial_state.get(role, {})) + self.agent_states[name] = AgentState( + name=name, role=role, team=team, attributes=attrs + ) + + self.current_phase = self.rules.initial_phase + self._winner_payload = None + self.state_flags = {} + + self._apply_action_mask() + self._last_events = Events( + public=[f"[God] Phase: {self.current_phase.title()}"] + ) + return self._augment_observations(base_obs, append_to_existing=True) + + # ----------------------------- + # Helpers + # ----------------------------- + def _role_for_agent(self, agent_name: str) -> str: + return self._role_assignments.get(agent_name, agent_name) + + def _phase_conf(self, phase: str) -> Dict[str, Any]: + assert self.rules is not None + return self.rules.phases.get(phase, {}) + + def _speech_visibility_for(self, phase: str, role: str) -> str: + conf = self._phase_conf(phase) + vis = conf.get("speech_visibility", {}) + return vis.get(role, vis.get("*", "public")) + + def _phase_actions_for_role(self, phase: str, role: str) -> List[str]: + assert self.actions is not None + layer = self.actions.phase_actions.get(phase, {}) + base = layer.get(role, layer.get("*", ["none"])) + if "none" not in base: + return list(base) + ["none"] + return list(base) + + def _eligible_actors(self, phase: str) -> List[str]: + conf = self._phase_conf(phase) + acting_roles = set(conf.get("acting_roles", [])) + acting_teams = set(conf.get("acting_teams", [])) + alive = [n for n, s in self.agent_states.items() if s.alive] + eligible = [] + for n in alive: + st = self.agent_states[n] + if acting_roles and st.role not in acting_roles: + continue + if acting_teams and st.team not in acting_teams: + continue + # require that role has any action other than none + if self._phase_actions_for_role(phase, st.role) != ["none"]: + eligible.append(n) + return eligible + + def active_agents_for_phase(self) -> List[str]: + return self._eligible_actors(self.current_phase) + + def available_actions(self, agent_name: str) -> List[str]: + if not self.agent_states[agent_name].alive: + return ["none"] + role = self.agent_states[agent_name].role + return self._phase_actions_for_role(self.current_phase, role) + + def _apply_action_mask(self) -> None: + acting = set(self.active_agents_for_phase()) + self.action_mask = [ + a in acting and self.agent_states[a].alive for a in self.agents + ] + + def _record_speech(self, actor: str, action: AgentAction) -> Events: + events = Events() + if action.action_type != "speak": + return events + text = action.argument.strip() + if not text: + return events + line = f'{actor} said: "{text}"' + vis = self._speech_visibility_for( + self.current_phase, self.agent_states[actor].role + ) + if vis == "team": + team = self.agent_states[actor].team + events.team.setdefault(team, []).append(line) + elif vis == "private": + events.private.setdefault(actor, []).append(line) + elif vis == "hidden": + return events + else: + events.public.append(line) + return events + + def _extract_target_from_text(self, text: str) -> Optional[str]: + corpus = text.lower() + for name in self.agent_states: + if name.lower() in corpus: + return name + for name in self.agent_states: + first = name.split()[0].lower() + if first in corpus: + return name + return None + + def _first_action_target( + self, actors: Sequence[str], actions: Dict[str, AgentAction] + ) -> Optional[str]: + for n in actors: + act = actions.get(n) + if act and act.action_type == "action": + t = self._extract_target_from_text(act.argument) + if t: + return t + return None + + # ----------------------------- + # Core loop + # ----------------------------- + async def astep( + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] + ) -> tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + assert self.rules is not None and self.actions is not None + self._apply_action_mask() + self.turn_number += 1 + + # Normalize actions + prepared: Dict[str, AgentAction] = {} + for agent, raw in actions.items(): + if isinstance(raw, AgentAction): + prepared[agent] = raw + else: + idx = int(raw.get("action_type", 0)) + action_type = self.available_action_types[idx] + prepared[agent] = AgentAction( + action_type=action_type, argument=str(raw.get("argument", "")) + ) + + self.recv_message( + "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") + ) + for agent, action in prepared.items(): + self.recv_message(agent, action) + + acting = set(self.active_agents_for_phase()) + events = Events() + + # Speech first + for actor in acting: + events.extend( + self._record_speech( + actor, + prepared.get(actor, AgentAction(action_type="none", argument="")), + ) + ) + + # Phase resolvers + resolvers = self._phase_conf(self.current_phase).get("resolvers", []) + for spec in resolvers: + op = spec.get("op") + if not op: + continue + handler = getattr(self, f"_op_{op}", None) + if handler is None: + continue + events.extend(handler(spec, acting, prepared)) + + winner = self._check_end_conditions() + if winner: + self._winner_payload = winner + + # Phase advance + if not winner: + self.current_phase = self.rules.next_phase.get( + self.current_phase, self.current_phase + ) + events.public.append(f"[God] Phase: {self.current_phase.title()}") + + self._last_events = events + self._apply_action_mask() + baseline = self._create_blank_observations() + observations = self._augment_observations(baseline, append_to_existing=False) + rewards = {a: 0.0 for a in self.agents} + terminated = {a: bool(winner) for a in self.agents} + truncations = {a: False for a in self.agents} + info = { + a: {"comments": winner["message"] if winner else "", "complete_rating": 0} + for a in self.agents + } + return observations, rewards, terminated, truncations, info + + def step( + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] + ) -> tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + return asyncio.run(self.astep(actions)) + + # ----------------------------- + # Generic resolvers + # ----------------------------- + def _op_store_target( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + """Aggregate acting agents' targets and store in a flag. + + spec: + - flag (str): flag name to set + - restrict_team (str, optional): only count actions from this team + - message (str, optional): format with {target} + - announce_to_team (str, optional): team name to receive announcement + """ + events = Events() + flag = spec.get("flag") + if not flag: + return events + restrict_team = spec.get("restrict_team") + + tally: Dict[str, int] = {} + for name in acting: + st = self.agent_states[name] + if restrict_team and st.team != restrict_team: + continue + act = actions.get(name) + if not act or act.action_type != "action": + continue + t = self._extract_target_from_text(act.argument) + if t: + tally[t] = tally.get(t, 0) + 1 + + target: Optional[str] = None + if tally: + target = max(tally.items(), key=lambda kv: kv[1])[0] + self.state_flags[flag] = target + msg_tmpl = spec.get("message") + if msg_tmpl: + line = msg_tmpl.format(target=target) + team_name = spec.get("announce_to_team") + if team_name: + events.team.setdefault(team_name, []).append(line) + else: + events.public.append(line) + return events + + def _op_reveal_attribute( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + """Reveal a target's attribute value to each acting agent privately. + + spec: + - attribute (str): attribute name, e.g., "team" + - message (str, optional): template with {target} and {value} + """ + events = Events() + attr = spec.get("attribute") + if not attr: + return events + msg_tmpl = spec.get("message", "[God] {target}: {value}") + for name in acting: + act = actions.get(name) + if not act or act.action_type != "action": + continue + target = self._extract_target_from_text(act.argument) + if not target: + continue + value = ( + self.agent_states[target].team + if attr == "team" + else self.agent_states[target].attributes.get(attr, "") + ) + events.private.setdefault(name, []).append( + msg_tmpl.format(target=target, value=value) + ) + return events + + def _op_keyword_target_flags( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + """Set one or more flags based on keywords found in actors' action text. + + spec: + - mapping: [{"keyword":"save","flag":"witch_saved","require_attr":"save_available","set_attr_false":true}, ...] + """ + events = Events() + mapping = spec.get("mapping", []) + if not mapping: + return events + for name in acting: + st = self.agent_states[name] + act = actions.get(name) + if not act or act.action_type != "action": + continue + text = act.argument.lower() + target = self._extract_target_from_text(act.argument) + for rule in mapping: + kw = rule.get("keyword", "").lower() + flag = rule.get("flag") + require_attr = rule.get("require_attr") + set_attr_false = bool(rule.get("set_attr_false", False)) + if not kw or not flag: + continue + if require_attr and not st.attributes.get(require_attr, True): + continue + if kw in text and target: + self.state_flags[flag] = target + if set_attr_false and require_attr: + st.attributes[require_attr] = False + return events + + def _op_kill_flags( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + """Kill agents listed by flags, excluding those present in exclude flags. + + spec: + - flags: ["night_target", "witch_poisoned"] + - exclude: ["witch_saved"] + - message_dead: template with {victim} + - message_peace: message if no one dies + """ + events = Events() + flags = spec.get("flags", []) + exclude = spec.get("exclude", []) + msg_dead = spec.get("message_dead", "[God] {victim} died.") + msg_peace = spec.get("message_peace", "[God] No one died.") + + victims: List[str] = [] + for f in flags: + val = self.state_flags.get(f) + if isinstance(val, str) and val: + victims.append(val) + excluded: List[str] = [] + for f in exclude: + val = self.state_flags.get(f) + if isinstance(val, str) and val: + excluded.append(val) + final = [v for v in victims if v not in excluded] + if not final: + events.public.append(msg_peace) + return events + for victim in final: + if victim in self.agent_states and self.agent_states[victim].alive: + self.agent_states[victim].alive = False + events.public.append(msg_dead.format(victim=victim)) + return events + + def _op_vote_majority_execute( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + """Tally votes and execute a unique winner. + + spec: + - tie_policy: "no_execute" (default) | "random" (not implemented) + - message_execute, message_tie, message_none + """ + events = Events() + msg_exec = spec.get( + "message_execute", + "[God] {target} was executed. They belonged to team {team}.", + ) + msg_tie = spec.get("message_tie", "[God] The vote is tied. No execution today.") + msg_none = spec.get("message_none", "[God] No valid votes were cast.") + + tally: Dict[str, int] = {} + for name in acting: + act = actions.get(name) + if not act or act.action_type != "action": + continue + t = self._extract_target_from_text(act.argument) + if t: + tally[t] = tally.get(t, 0) + 1 + + if not tally: + events.public.append(msg_none) + return events + + winner, votes = max(tally.items(), key=lambda kv: kv[1]) + if list(tally.values()).count(votes) > 1: + events.public.append(msg_tie) + return events + + if winner in self.agent_states and self.agent_states[winner].alive: + self.agent_states[winner].alive = False + team = self.agent_states[winner].team + events.public.append(msg_exec.format(target=winner, team=team)) + return events + + def _op_clear_flags( + self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] + ) -> Events: + events = Events() + for f in spec.get("flags", []): + self.state_flags.pop(f, None) + return events + + # ----------------------------- + # Observations + # ----------------------------- + def _create_blank_observations(self) -> Dict[str, Observation]: + blank: Dict[str, Observation] = {} + acting = set(self.active_agents_for_phase()) + for name in self.agents: + available = self.available_actions(name) if name in acting else ["none"] + blank[name] = Observation( + last_turn="", turn_number=self.turn_number, available_actions=available + ) + return blank + + def _augment_observations( + self, + baseline: Dict[str, Observation], + *, + append_to_existing: bool, + ) -> Dict[str, Observation]: + acting = set(self.active_agents_for_phase()) + events = self._last_events + new_obs: Dict[str, Observation] = {} + for idx, name in enumerate(self.agents): + current = baseline[name] + available = self.available_actions(name) if name in acting else ["none"] + lines: List[str] = [] + if append_to_existing and current.last_turn.strip(): + lines.append(current.last_turn.strip()) + lines.append(f"[God] Phase: {self.current_phase.title()}") + lines.append( + "[God] It is your turn to act." + if name in acting + else "[God] You are observing this phase." + ) + lines.append(f"[God] Available actions: {', '.join(available)}") + + team = self.agent_states[name].team + msgs: List[str] = [] + msgs.extend(events.public) + msgs.extend(events.team.get(team, [])) + msgs.extend(events.private.get(name, [])) + if not msgs: + msgs.append("[God] Await instructions from the host.") + lines.extend(msgs) + + combined = "\n".join(seg for seg in lines if seg) + new_obs[name] = Observation( + last_turn=render_text_for_agent(combined, agent_id=idx), + turn_number=current.turn_number, + available_actions=available, + ) + return new_obs + + # ----------------------------- + # End conditions (generic) + # ----------------------------- + def _check_end_conditions(self) -> Optional[Dict[str, str]]: + assert self.rules is not None + alive_by_team: Dict[str, int] = {} + for s in self.agent_states.values(): + if s.alive: + alive_by_team[s.team] = alive_by_team.get(s.team, 0) + 1 + for cond in self.rules.end_conditions: + ctype = cond.get("type") + if ctype == "team_eliminated": + team = cond.get("team", "") + if alive_by_team.get(team, 0) == 0: + return { + "winner": cond.get("winner", team), + "message": cond.get("message", f"[God] {team} eliminated."), + } + if ctype == "parity": + team = cond.get("team", "") + other = cond.get("other", "") + if alive_by_team.get(team, 0) >= alive_by_team.get(other, 0) > 0: + return { + "winner": cond.get("winner", team), + "message": cond.get( + "message", + f"[God] Parity reached: {team} now matches or exceeds {other}.", + ), + } + return None diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index 38d1585f7..f805a431e 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -71,12 +71,12 @@ def sample( if game_meta.get("mode") == "social_game": from sotopia.envs import SocialGameEnv + config_path = game_meta.get("config_path") + assert ( + config_path + ), "game_metadata.config_path is required for social_game" env = SocialGameEnv( - env_profile=env_profile, - rulebook_path=game_meta["rulebook_path"], - actions_path=game_meta["actions_path"], - role_assignments=game_meta["role_assignments"], - **env_params, + env_profile=env_profile, config_path=config_path, **env_params ) else: env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) From df62578f2449d61f41bb6b017b6a61a0f6d11094 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Thu, 13 Nov 2025 14:19:30 -0500 Subject: [PATCH 06/21] fix mypy errors --- sotopia/generation_utils/generate.py | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index ebd91ce29..e4a9c84cb 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -98,7 +98,7 @@ async def format_bad_output( content = template.format(**input_values) # Build completion kwargs - completion_kwargs = { + completion_kwargs: dict[str, Any] = { "model": model_name, "messages": [{"role": "user", "content": content}], } diff --git a/uv.lock b/uv.lock index a0d147290..2e38d6dd9 100644 --- a/uv.lock +++ b/uv.lock @@ -3163,7 +3163,7 @@ requires-dist = [ { name = "google-generativeai", marker = "extra == 'google-generativeai'" }, { name = "groq", marker = "extra == 'groq'" }, { name = "hiredis", specifier = ">=3.0.0" }, - { name = "json-repair", specifier = ">=0.35.0,<0.45.0" }, + { name = "json-repair", specifier = ">=0.35.0,<0.49.0" }, { name = "litellm", specifier = ">=1.65.0" }, { name = "lxml", specifier = ">=4.9.3,<6.0.0" }, { name = "modal", marker = "extra == 'api'" }, From b4536339e7f2cae8ac6c67874feebbe11948d048 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Fri, 14 Nov 2025 14:11:18 -0500 Subject: [PATCH 07/21] Design Social Game class, werewolf demo working in progress --- .../experimental/werewolves/callgraph.dot | 0 examples/experimental/werewolves/config.json | 5 +- .../experimental/werewolves/game_rules.json | 216 ------ examples/experimental/werewolves/main.py | 379 ++++++---- .../experimental/werewolves/role_actions.json | 75 -- examples/experimental/werewolves/roster.json | 61 -- sotopia/agents/base_agent.py | 9 +- sotopia/envs/action_processor.py | 253 +++---- sotopia/envs/evaluators.py | 53 +- sotopia/envs/social_game.py | 648 +++++++++-------- sotopia/envs/social_game_legacy.py | 464 ------------- sotopia/envs/social_game_legacy2.py | 656 ------------------ sotopia/server.py | 45 +- 13 files changed, 853 insertions(+), 2011 deletions(-) delete mode 100644 examples/experimental/werewolves/callgraph.dot delete mode 100644 examples/experimental/werewolves/game_rules.json delete mode 100644 examples/experimental/werewolves/role_actions.json delete mode 100644 examples/experimental/werewolves/roster.json delete mode 100644 sotopia/envs/social_game_legacy.py delete mode 100644 sotopia/envs/social_game_legacy2.py diff --git a/examples/experimental/werewolves/callgraph.dot b/examples/experimental/werewolves/callgraph.dot deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 541326b59..0d8ad04dc 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -21,7 +21,7 @@ { "name": "Elise", "role": "Witch" , "team": "Villagers"}, { "name": "Finn", "role": "Villager" , "team": "Villagers"} ], - "initial_state": "Night_werewolf", + "initial_state": "Day_discussion", "state_transition": { "Night_werewolf": "Night_seer", "Night_seer": "Night_witch", @@ -55,7 +55,8 @@ }, "Day_vote": { "actions": ["action"], - "visibility": { "during": "private", "after": "public" } + "action_order": "simultaneous", + "visibility": "public" } }, "end_conditions": [ diff --git a/examples/experimental/werewolves/game_rules.json b/examples/experimental/werewolves/game_rules.json deleted file mode 100644 index 9036b49cb..000000000 --- a/examples/experimental/werewolves/game_rules.json +++ /dev/null @@ -1,216 +0,0 @@ -{ - "initial_phase": "night_werewolves", - "phases": [ - { - "name": "night_werewolves", - "kind": "team_target", - "turn_mode": "simultaneous", - "acting_roles": [ - "Werewolf" - ], - "acting_teams": [ - "Werewolves" - ], - "speech_visibility": "team", - "action_visibility": "team", - "resolution": { - "operation": "store_target", - "state_key": "night_target", - "visibility": "team" - }, - "entry_messages": [ - "Night phase: werewolves pick a target." - ], - "exit_messages": [ - "Werewolves have chosen their move." - ], - "group": "night", - "instructions": [ - "Secret night phase. Only werewolves act here." - ], - "role_instructions": { - "Werewolf": [ - "Coordinate quietly with packmates and issue 'kill NAME'." - ] - } - }, - { - "name": "night_seer", - "kind": "single_target", - "turn_mode": "single", - "acting_roles": [ - "Seer" - ], - "speech_visibility": "private", - "action_visibility": "private", - "resolution": { - "operation": "seer_inspect", - "visibility": "private" - }, - "entry_messages": [ - "Seer, choose someone to inspect." - ], - "exit_messages": [ - "Seer's vision is complete." - ], - "group": "night", - "instructions": [ - "Seer takes a private action." - ], - "role_instructions": { - "Seer": [ - "Use 'inspect NAME' to learn their alignment." - ] - } - }, - { - "name": "night_witch", - "kind": "single_target", - "turn_mode": "single", - "acting_roles": [ - "Witch" - ], - "speech_visibility": "private", - "action_visibility": "private", - "resolution": { - "operation": "witch_phase", - "visibility": "private" - }, - "entry_messages": [ - "Witch, decide to save, poison, or pass." - ], - "exit_messages": [ - "Witch phase ends." - ], - "group": "night", - "instructions": [ - "Witch decides whether to intervene." - ], - "role_instructions": { - "Witch": [ - "Choose 'save NAME', 'poison NAME', or 'pass'. Each potion may be used once." - ] - } - }, - { - "name": "dawn_report", - "kind": "announcement", - "turn_mode": "simultaneous", - "resolution": { - "operation": "resolve_night", - "visibility": "public" - }, - "entry_messages": [ - "Dawn report:" - ], - "exit_messages": [], - "group": "night", - "instructions": [ - "Public summary of night outcomes." - ], - "role_instructions": {} - }, - { - "name": "day_discussion", - "kind": "discussion", - "turn_mode": "round-robin", - "acting_roles": [ - "Villager", - "Seer", - "Witch", - "Werewolf" - ], - "max_cycles": 1, - "max_turns": null, - "speech_visibility": "public", - "action_visibility": "public", - "resolution": { - "operation": "noop" - }, - "entry_messages": [ - "Day discussion starts. Speak in turn." - ], - "exit_messages": [ - "Discussion ends." - ], - "group": "day", - "instructions": [ - "Each villager speaks once in turn. Share concise reasoning tied to observations." - ], - "role_instructions": {} - }, - { - "name": "day_vote", - "kind": "vote", - "turn_mode": "simultaneous", - "acting_roles": [ - "Villager", - "Seer", - "Witch", - "Werewolf" - ], - "speech_visibility": "hidden", - "action_visibility": "public", - "resolution": { - "operation": "vote", - "visibility": "public" - }, - "entry_messages": [ - "Voting phase: respond with action 'vote NAME' or 'vote none'. Do not speak." - ], - "max_turns": 1, - "exit_messages": [ - "Votes are tallied." - ], - "group": "day", - "instructions": [ - "Voting phase: respond with action 'vote NAME' or 'vote none'. Do not speak." - ], - "role_instructions": {} - }, - { - "name": "twilight_execution", - "kind": "announcement", - "turn_mode": "simultaneous", - "resolution": { - "operation": "post_vote_cleanup", - "visibility": "public" - }, - "entry_messages": [ - "Execution results:" - ], - "exit_messages": [ - "Night returns." - ], - "group": "day", - "instructions": [ - "Resolve the vote and announce results." - ], - "role_instructions": {} - } - ], - "phase_transitions": { - "night_werewolves": "night_seer", - "night_seer": "night_witch", - "night_witch": "dawn_report", - "dawn_report": "day_discussion", - "day_discussion": "day_vote", - "day_vote": "twilight_execution", - "twilight_execution": "night_werewolves" - }, - "end_conditions": [ - { - "operation": "team_eliminated", - "team": "Werewolves", - "winner": "Villagers", - "message": "[God] Villagers win; no werewolves remain." - }, - { - "operation": "parity", - "team": "Werewolves", - "other_team": "Villagers", - "winner": "Werewolves", - "message": "[God] Werewolves win; they now match the village." - } - ] -} diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index 0e9bfb162..839a2e4bb 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -7,7 +7,7 @@ import os from pathlib import Path import logging -from typing import Any, Dict, List, cast, Tuple +from typing import Any, Dict, List, cast import redis @@ -18,13 +18,9 @@ RelationshipType, ) from sotopia.envs import SocialGameEnv -from sotopia.envs.evaluators import ( - EpisodeLLMEvaluator, - EvaluationForAgents, - RuleBasedTerminatedEvaluator, -) +from sotopia.envs.evaluators import SocialGameEndEvaluator from sotopia.server import arun_one_episode -from sotopia.database import SotopiaDimensions +from sotopia.messages import AgentAction, SimpleMessage, Message BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "config.json" @@ -32,9 +28,8 @@ os.environ.setdefault("REDIS_OM_URL", "redis://:@localhost:6379") redis.Redis(host="localhost", port=6379) -# Configure debug file logging for generation traces +# Configure debug file logging LOG_FILE = BASE_DIR / "werewolves_game_debug.log" -# _fh is the file handler, which is used to log the >=DEBUG levels to a .log file. _fh = logging.FileHandler(LOG_FILE, mode="w", encoding="utf-8") _fh.setFormatter( logging.Formatter("%(asctime)s %(levelname)-7s %(name)s - %(message)s") @@ -44,168 +39,296 @@ _gen_logger.addHandler(_fh) -def load_json(path: Path) -> Dict[str, Any]: - return cast(Dict[str, Any], json.loads(path.read_text())) - - -def split_name(full_name: str) -> Tuple[str, str]: - first_name, last_name = ( - full_name.split(" ", 1) if " " in full_name else (full_name, "") - ) - return first_name, last_name - - -# for god roles (seer, witch), their roles can be revealed to other players - - -def ensure_agent(player: Dict[str, Any]) -> AgentProfile: +# ============================================================================ +# Werewolf game-end evaluator +# ============================================================================ + + +class WerewolfGameEndEvaluator(SocialGameEndEvaluator): + """Evaluator that checks werewolf win conditions.""" + + def _check_win_conditions( + self, env: Any, turn_number: int, messages: List[tuple[str, Message]] + ) -> tuple[bool, str]: + """Check if game has ended based on werewolf win conditions.""" + # Count alive players by team + team_counts: Dict[str, int] = {} + for agent_name, alive in env.agent_alive.items(): + if alive: + role = env.agent_to_role.get(agent_name, "") + team = env.role_to_team.get(role, "") + team_counts[team] = team_counts.get(team, 0) + 1 + + # Check end conditions from config + end_conditions = env._config.get("end_conditions", []) + for condition in end_conditions: + cond_type = condition.get("type") + + if cond_type == "team_eliminated": + team = condition.get("team", "") + if team_counts.get(team, 0) == 0: + winner = condition.get("winner", "") + msg = condition.get("message", f"{winner} wins!") + env.recv_message("Environment", SimpleMessage(message=msg)) + return True, msg + + elif cond_type == "parity": + team1 = condition.get("team", "") + team2 = condition.get("other", "") + if team_counts.get(team1, 0) >= team_counts.get(team2, 0): + winner = condition.get("winner", "") + msg = condition.get("message", f"{winner} wins!") + env.recv_message("Environment", SimpleMessage(message=msg)) + return True, msg + + return False, "" + + +# ============================================================================ +# Werewolf-specific game logic +# ============================================================================ + + +class WerewolfEnv(SocialGameEnv): + """Werewolf game with voting, kills, and special roles.""" + + def _process_actions(self, actions: Dict[str, AgentAction]) -> None: + """Collect votes, kills, inspections, etc. based on current state.""" + + if self.current_state == "Day_vote": + # Collect votes for elimination + if "votes" not in self.internal_state: + self.internal_state["votes"] = {} + + for agent_name, action in actions.items(): + if action.action_type == "action" and "vote" in action.argument.lower(): + # Parse target from "vote Aurora" or "I vote for Aurora" + words = action.argument.split() + # Try to find a name (capitalized word) + target = next( + (w for w in words if w[0].isupper() and w in self.agents), None + ) + if target: + self.internal_state["votes"][agent_name] = target + + elif self.current_state == "Night_werewolf": + # Werewolves choose kill target + for agent_name, action in actions.items(): + role = self.agent_to_role.get(agent_name, "") + if role == "Werewolf" and action.action_type == "action": + if "kill" in action.argument.lower(): + words = action.argument.split() + target = next( + (w for w in words if w[0].isupper() and w in self.agents), + None, + ) + if target: + self.internal_state["kill_target"] = target + + elif self.current_state == "Night_seer": + # Seer inspects someone + for agent_name, action in actions.items(): + role = self.agent_to_role.get(agent_name, "") + if role == "Seer" and action.action_type == "action": + if "inspect" in action.argument.lower(): + words = action.argument.split() + target = next( + (w for w in words if w[0].isupper() and w in self.agents), + None, + ) + if target: + # Reveal target's role to seer + target_role = self.agent_to_role.get(target, "Unknown") + target_team = self.role_to_team.get(target_role, "Unknown") + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Private to {agent_name}] {target} is on team: {target_team}" + ), + ) + + def _check_eliminations(self) -> None: + """Apply eliminations based on collected actions.""" + + if self.current_state == "Day_vote": + # Tally votes and eliminate most voted player + votes = self.internal_state.get("votes", {}) + if votes: + vote_counts: Dict[str, int] = {} + for target in votes.values(): + vote_counts[target] = vote_counts.get(target, 0) + 1 + + if vote_counts: + # Find player with most votes + eliminated = max(vote_counts, key=vote_counts.get) # type: ignore + self.agent_alive[eliminated] = False + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Game] {eliminated} was voted out! They were a {self.agent_to_role[eliminated]}." + ), + ) + # Clear votes + self.internal_state["votes"] = {} + + elif self.current_state == "Night_werewolf": + # Apply werewolf kill + target = self.internal_state.get("kill_target") + if target and self.agent_alive.get(target, False): + # Check if witch saves them (would be in Night_witch state) + saved = self.internal_state.get("saved_target") + if target != saved: + self.agent_alive[target] = False + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Game] {target} was killed by werewolves!" + ), + ) + else: + self.recv_message( + "Environment", + SimpleMessage(message="[Game] An attack was prevented!"), + ) + # Clear kill target + self.internal_state.pop("kill_target", None) + + +# ============================================================================ +# Setup helpers +# ============================================================================ + + +def load_config() -> Dict[str, Any]: + """Load game configuration.""" + return cast(Dict[str, Any], json.loads(CONFIG_PATH.read_text())) + + +def ensure_agent_profile(name: str, role: str, config: Dict[str, Any]) -> AgentProfile: + """Create or retrieve agent profile.""" + first_name, _, last_name = name.partition(" ") + if not last_name: + last_name = "" + + # Try to find existing try: existing = AgentProfile.find( - (AgentProfile.first_name == player["first_name"]) - & (AgentProfile.last_name == player["last_name"]) # combine predicates + (AgentProfile.first_name == first_name) + & (AgentProfile.last_name == last_name) ).all() + if existing: + return AgentProfile.get(existing[0].pk) except Exception: - existing = [] - if existing: - prof = AgentProfile.get(existing[0].pk) - return prof + pass + # Create new + role_secret = config.get("role_secrets", {}).get(role, "") profile = AgentProfile( - first_name=player["first_name"], - last_name=player["last_name"], - age=player.get("age", ""), - occupation=player.get("occupation", ""), - gender=player.get("gender", ""), - gender_pronoun=player.get("pronouns", ""), - public_info=player.get("public_info", ""), - personality_and_values=player.get("personality_and_values", ""), - decision_making_style=player.get("decision_making_style", ""), - secret=player.get("secret", ""), + first_name=first_name, + last_name=last_name, + age=30, + secret=role_secret, ) profile.save() return profile -def prepare_scenario() -> tuple[EnvironmentProfile, List[AgentProfile], Dict[str, str]]: - assert CONFIG_PATH.exists(), f"config.json not found at {CONFIG_PATH}" - cfg = load_json(CONFIG_PATH) - agents: List[AgentProfile] = [] - role_assignments: Dict[str, str] = {} - - for entry in cfg.get("agents", []): - full_name = cast(str, entry.get("name", "Unknown Name")) - role = cast(str, entry.get("role", "Unknown Role")) - first_name, last_name = split_name(full_name) - role_goal = cfg.get("role_goals", {}).get(role, "") - role_secret = cfg.get("role_secrets", {}).get(role, "") - # Build a complete player payload for profile creation/update - player: Dict[str, Any] = { - "first_name": first_name, - "last_name": last_name, - "pronouns": entry.get("pronouns", ""), - "age": entry.get("age", ""), - "gender": entry.get("gender", ""), - "occupation": entry.get("occupation", ""), - "public_info": entry.get("public_info", ""), - "personality_and_values": entry.get("personality_and_values", ""), - "decision_making_style": entry.get("decision_making_style", ""), - "goal": role_goal, - "secret": role_secret, - } - profile = ensure_agent(player) - agents.append(profile) - role_assignments[full_name] = role - - scenario_text = cast( - str, cfg.get("description") or cfg.get("scenario") or "Werewolves game" - ) - env_profile = EnvironmentProfile( - scenario=scenario_text, - relationship=RelationshipType.acquaintance, - game_metadata={ - "mode": "social_game", - "config_path": str(CONFIG_PATH), - }, - tag="werewolves", - ) - env_profile.save() - return env_profile, agents, role_assignments - - -def build_environment( - env_profile: EnvironmentProfile, - role_assignments: Dict[str, str], - model_name: str, -) -> SocialGameEnv: - return SocialGameEnv( +def create_environment(env_profile: EnvironmentProfile, model_name: str) -> WerewolfEnv: + """Create werewolf game environment.""" + return WerewolfEnv( env_profile=env_profile, config_path=str(CONFIG_PATH), model_name=model_name, action_order="round-robin", - evaluators=[RuleBasedTerminatedEvaluator(max_turn_number=40, max_stale_turn=2)], - terminal_evaluators=[ - EpisodeLLMEvaluator( - model_name, - EvaluationForAgents[SotopiaDimensions], - ) - ], + evaluators=[WerewolfGameEndEvaluator(max_turn_number=40)], + terminal_evaluators=[], ) def create_agents( agent_profiles: List[AgentProfile], env_profile: EnvironmentProfile, - model_names: List[str], - default_model: str, + model_name: str, ) -> List[LLMAgent]: - cfg = load_json(CONFIG_PATH) - cfg_agents = cfg.get("agents", []) - agents: List[LLMAgent] = [] + """Create LLM agents.""" + agents = [] for idx, profile in enumerate(agent_profiles): - # priority: explicit model_names list > per-agent config override > default_model - if idx < len(model_names) and model_names[idx]: - model_name = model_names[idx] - elif idx < len(cfg_agents) and cfg_agents[idx].get("model"): - model_name = cast(str, cfg_agents[idx]["model"]) - else: - model_name = default_model agent = LLMAgent(agent_profile=profile, model_name=model_name) agent.goal = env_profile.agent_goals[idx] agents.append(agent) return agents -def print_roster(role_assignments: Dict[str, str]) -> None: +def prepare_scenario( + env_model_name: str, agent_model_name: str +) -> tuple[SocialGameEnv, List[LLMAgent]]: + """Load config and create profiles.""" + config = load_config() + + # Create agent profiles + agent_profiles = [] + agent_goals = [] + for entry in config.get("agents", []): + name = entry.get("name", "Unknown") + role = entry.get("role", "Villager") + + profile = ensure_agent_profile(name, role, config) + agent_profiles.append(profile) + + role_goal = config.get("role_goals", {}).get(role, "") + agent_goals.append(role_goal) + + # Create environment profile + scenario = config.get("description", "Werewolves game") + env_profile = EnvironmentProfile( + scenario=scenario, + relationship=RelationshipType.acquaintance, + agent_goals=agent_goals, + tag="werewolves", + ) + env_profile.save() + + env = create_environment(env_profile, env_model_name) + agents = create_agents(agent_profiles, env_profile, agent_model_name) + return env, agents + + +def print_roster(config: Dict[str, Any]) -> None: + """Print game roster.""" print("Participants & roles:") - for name, role in role_assignments.items(): + for entry in config.get("agents", []): + name = entry.get("name", "Unknown") + role = entry.get("role", "Unknown") print(f" - {name}: {role}") +# ============================================================================ +# Main +# ============================================================================ + + async def main() -> None: - env_profile, agent_profiles, role_assignments = prepare_scenario() - env_model = "gpt-5" - agent_model_list = [ - "gpt-5", - "gpt-5", - "gpt-5", - "gpt-5", - "gpt-5", - "gpt-5", - ] - - env = build_environment(env_profile, role_assignments, env_model) - agents = create_agents(agent_profiles, env_profile, agent_model_list, env_model) + """Run werewolf game.""" + # Configuration + env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + + # Setup + env, agents = prepare_scenario(env_model_name, agent_model_name) + # Display roster + config = load_config() print("🌕 Duskmire Werewolves") print("=" * 60) - print_roster(role_assignments) + print_roster(config) print("=" * 60) + # Run game await arun_one_episode( env=env, agent_list=agents, omniscient=False, - script_like=False, + script_like=True, # Required for action_mask to work json_in_script=False, tag=None, push_to_db=False, diff --git a/examples/experimental/werewolves/role_actions.json b/examples/experimental/werewolves/role_actions.json deleted file mode 100644 index 2c88851a9..000000000 --- a/examples/experimental/werewolves/role_actions.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "roles": { - "Villager": { - "name": "Villager", - "team": "Villagers", - "description": "Ordinary resident with no night power but vital voice in daytime debates.", - "goal_prompt": "Keep sharp notes about player behaviour and vote to execute suspected werewolves each day.", - "default_actions": ["speak"], - "phase_actions": { - "night_werewolves": ["none"], - "night_seer": ["none"], - "night_witch": ["none"], - "dawn_report": ["none"], - "day_discussion": ["speak"], - "day_vote": ["action"], - "twilight_execution": ["none"] - }, - "initial_state": {} - }, - "Seer": { - "name": "Seer", - "team": "Villagers", - "description": "Mystic who divines alignments during the night.", - "goal_prompt": "Inspect one player each night using an action like 'inspect NAME'; leak findings strategically without exposing yourself too early.", - "default_actions": ["speak"], - "phase_actions": { - "night_werewolves": ["none"], - "night_seer": ["action"], - "night_witch": ["none"], - "dawn_report": ["none"], - "day_discussion": ["speak"], - "day_vote": ["action"], - "twilight_execution": ["none"] - }, - "initial_state": {} - }, - "Witch": { - "name": "Witch", - "team": "Villagers", - "description": "Potion expert who may save one player per game and poison one player per game during the night.", - "goal_prompt": "During your witch phase, decide whether to 'save NAME', 'poison NAME', or pass. Use your limited potions wisely to keep villagers alive and remove wolves when confident.", - "default_actions": ["speak"], - "phase_actions": { - "night_werewolves": ["none"], - "night_seer": ["none"], - "night_witch": ["action"], - "dawn_report": ["none"], - "day_discussion": ["speak"], - "day_vote": ["action"], - "twilight_execution": ["none"] - }, - "initial_state": { - "save_available": true, - "poison_available": true - } - }, - "Werewolf": { - "name": "Werewolf", - "team": "Werewolves", - "description": "Predator hiding among villagers, coordinating nightly kills and sowing mistrust by day.", - "goal_prompt": "Confer quietly with fellow wolves at night. Use actions like 'kill NAME' to propose a victim. During the day, blend in while pushing misdirection.", - "default_actions": ["speak"], - "phase_actions": { - "night_werewolves": ["speak", "action"], - "night_seer": ["none"], - "night_witch": ["none"], - "dawn_report": ["none"], - "day_discussion": ["speak"], - "day_vote": ["action"], - "twilight_execution": ["none"] - }, - "initial_state": {} - } - } -} diff --git a/examples/experimental/werewolves/roster.json b/examples/experimental/werewolves/roster.json deleted file mode 100644 index 6abb27ea3..000000000 --- a/examples/experimental/werewolves/roster.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "scenario": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players openly discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. STRATEGY: Villagers must use logic, observation, and deduction to identify Werewolves through their behavior and voting patterns. Werewolves must deceive others, blend in as Villagers, and create confusion. Special roles (Seer, Witch) should use their powers strategically without revealing themselves too early. Trust is earned through consistent behavior and alignment of words with actions.", - "players": [ - { - "first_name": "Aurora", - "last_name": "Harper", - "role": "Villager", - "public_role": "Villager", - "age": 54, - "pronouns": "she/her", - "goal": "Keep discussion orderly and support executions only when evidence feels solid." - }, - { - "first_name": "Bram", - "last_name": "Nightshade", - "role": "Werewolf", - "public_role": "Villager", - "age": 33, - "pronouns": "he/him", - "goal": "Blend in with confident speech while steering suspicion toward ordinary villagers.", - "secret": "You are a werewolf working with Dorian. Coordinate night kills." - }, - { - "first_name": "Celeste", - "last_name": "Moonseer", - "role": "Seer", - "public_role": "Villager", - "age": 29, - "pronouns": "she/her", - "goal": "Inspect one player per night and nudge the village toward the wolves." - }, - { - "first_name": "Dorian", - "last_name": "Blackwood", - "role": "Werewolf", - "public_role": "Villager", - "age": 38, - "pronouns": "he/him", - "goal": "Support Bram's stories and pressure outspoken villagers into missteps.", - "secret": "You are a werewolf working with Bram. Coordinate night kills." - }, - { - "first_name": "Elise", - "last_name": "Farrow", - "role": "Witch", - "public_role": "Villager", - "age": 41, - "pronouns": "she/her", - "goal": "Use your save and poison sparingly; protect confirmed villagers and strike when a wolf is exposed." - }, - { - "first_name": "Finn", - "last_name": "Alder", - "role": "Villager", - "public_role": "Villager", - "age": 36, - "pronouns": "he/him", - "goal": "Track inconsistencies and rally the town to execute the most suspicious player each day." - } - ] -} diff --git a/sotopia/agents/base_agent.py b/sotopia/agents/base_agent.py index 1454104e5..d80897251 100644 --- a/sotopia/agents/base_agent.py +++ b/sotopia/agents/base_agent.py @@ -19,14 +19,19 @@ def __init__( MessengerMixin.__init__(self) if agent_profile is not None: self.profile = agent_profile - self.agent_name = self.profile.first_name + " " + self.profile.last_name + self.agent_name = ( + self.profile.first_name + " " + self.profile.last_name + ).strip() + elif uuid_str is not None: # try retrieving profile from database try: self.profile = AgentProfile.get(pk=uuid_str) except NotFoundError: raise ValueError(f"Agent with uuid {uuid_str} not found in database") - self.agent_name = self.profile.first_name + " " + self.profile.last_name + self.agent_name = ( + self.profile.first_name + " " + self.profile.last_name + ).strip() else: assert ( agent_name is not None diff --git a/sotopia/envs/action_processor.py b/sotopia/envs/action_processor.py index 514dc2f68..5d459b9a1 100644 --- a/sotopia/envs/action_processor.py +++ b/sotopia/envs/action_processor.py @@ -1,117 +1,136 @@ -from __future__ import annotations - -import itertools -import random -from typing import Any, Dict, Tuple - -from sotopia.messages import AgentAction, Observation, SimpleMessage -from sotopia.envs.evaluators import unweighted_aggregate_evaluate -from sotopia.envs.parallel import render_text_for_agent, _actions_to_natural_language - - -class PlainActionProcessor: - """Stateless processor that turns raw actions into observations using base env semantics. - - This class expects an `env` object with attributes/methods used below (ParallelSotopiaEnv-compatible): - - agents, available_action_types, action_mask, action_order, evaluators, inbox, - recv_message, turn_number - """ - - def process( - self, - env: Any, - actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], - ) -> Tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - # 1) Compile actions to AgentAction - complied_actions: Dict[str, AgentAction] = {} - for key, raw in actions.items(): - if isinstance(raw, AgentAction): - complied_actions[key] = raw - else: - raw["action_type"] = env.available_action_types[int(raw["action_type"])] - complied_actions[key] = AgentAction.parse_obj(raw) - - # 2) Apply action mask - non-turn agents are forced to none - for idx, agent in enumerate(env.agents): - if not env.action_mask[idx]: - complied_actions[agent] = AgentAction(action_type="none", argument="") - - # 3) Record messages - env.recv_message( - "Environment", SimpleMessage(message=f"Turn #{env.turn_number}") - ) - for agent, action in complied_actions.items(): - env.recv_message(agent, action) - - # 4) Evaluate turn - response = unweighted_aggregate_evaluate( - list( - itertools.chain( - *( - evaluator(turn_number=env.turn_number, messages=env.inbox) - for evaluator in env.evaluators - ) - ) - ) - ) - - # 5) Next-turn action mask policy - env.action_mask = [False for _ in env.agents] - if env.action_order == "round-robin": - env.action_mask[env.turn_number % len(env.action_mask)] = True - elif env.action_order == "random": - env.action_mask[random.randint(0, len(env.action_mask) - 1)] = True - else: - env.action_mask = [True for _ in env.agents] - - # 6) Build observations - obs_text = _actions_to_natural_language(complied_actions) - observations: Dict[str, Observation] = {} - for i, agent_name in enumerate(env.agents): - observations[agent_name] = Observation( - last_turn=render_text_for_agent(obs_text, agent_id=i), - turn_number=env.turn_number, - available_actions=list(env.available_action_types) - if env.action_mask[i] - else ["none"], - ) - - # 7) Rewards/termination/truncation/info - rewards = {agent_name: 0.0 for agent_name in env.agents} - terminated = {agent_name: response.terminated for agent_name in env.agents} - truncations = {agent_name: False for agent_name in env.agents} - info = { - agent_name: {"comments": response.comments or "", "complete_rating": 0} - for agent_name in env.agents - } - return observations, rewards, terminated, truncations, info - - -class SocialGameActionProcessor(PlainActionProcessor): - """Extension point for social game state machines. - - Override/extend hooks to implement per-state masking, visibility, and transitions. - """ - - def process( - self, - env: Any, - actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], - ) -> Tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - # Optionally apply pre-processing (e.g., state-based masking) here - result = super().process(env, actions) - # Optionally apply post-processing (e.g., state transitions, visibility logs) here - # self._apply_state_transition(env, actions) # implement as needed - return result +# from __future__ import annotations + +# import itertools +# import random +# from typing import Any, Dict, Tuple +# from logging import logging + +# from sotopia.messages import AgentAction, Observation, SimpleMessage +# from sotopia.envs.evaluators import unweighted_aggregate_evaluate +# from sotopia.envs.parallel import render_text_for_agent, _actions_to_natural_language + + +# class ActionProcessor: +# """Just builds next observations from actions. Environment would handle the rest.""" + +# def process(self, env: Any, actions: Dict[str, Any]): +# # Record actions (env already has this via recv_message) +# for agent, action in actions.items(): +# env.recv_message(agent, action) +# logging.debug(f"Agent {agent} performed action: {action}") + +# # Build the next observations +# observations = {} +# for agent_name in env.agents: +# observations[agent_name] = Observation( +# last_turn= +# turn_number=env.turn_number, +# available_actions +# ) + +# class PlainActionProcessor: +# """Stateless processor that turns raw actions into observations using base env semantics. + +# This class expects an `env` object with attributes/methods used below (ParallelSotopiaEnv-compatible): +# - agents, available_action_types, action_mask, action_order, evaluators, inbox, +# recv_message, turn_number +# """ + +# def process( +# self, +# env: Any, +# actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], +# ) -> Tuple[ +# Dict[str, Observation], +# Dict[str, float], +# Dict[str, bool], +# Dict[str, bool], +# Dict[str, Dict[Any, Any]], +# ]: +# # 1) Compile actions to AgentAction +# complied_actions: Dict[str, AgentAction] = {} +# for key, raw in actions.items(): +# if isinstance(raw, AgentAction): +# complied_actions[key] = raw +# else: +# raw["action_type"] = env.available_action_types[int(raw["action_type"])] +# complied_actions[key] = AgentAction.parse_obj(raw) + +# # 2) Apply action mask - non-turn agents are forced to none +# for idx, agent in enumerate(env.agents): +# if not env.action_mask[idx]: +# complied_actions[agent] = AgentAction(action_type="none", argument="") + +# # 3) Record messages +# env.recv_message( +# "Environment", SimpleMessage(message=f"Turn #{env.turn_number}") +# ) +# for agent, action in complied_actions.items(): +# env.recv_message(agent, action) + +# # 4) Evaluate turn +# response = unweighted_aggregate_evaluate( +# list( +# itertools.chain( +# *( +# evaluator(turn_number=env.turn_number, messages=env.inbox) +# for evaluator in env.evaluators +# ) +# ) +# ) +# ) + +# # 5) Next-turn action mask policy +# env.action_mask = [False for _ in env.agents] +# if env.action_order == "round-robin": +# env.action_mask[env.turn_number % len(env.action_mask)] = True +# elif env.action_order == "random": +# env.action_mask[random.randint(0, len(env.action_mask) - 1)] = True +# else: +# env.action_mask = [True for _ in env.agents] + +# # 6) Build observations +# obs_text = _actions_to_natural_language(complied_actions) +# observations: Dict[str, Observation] = {} +# for i, agent_name in enumerate(env.agents): +# observations[agent_name] = Observation( +# last_turn=render_text_for_agent(obs_text, agent_id=i), +# turn_number=env.turn_number, +# available_actions=list(env.available_action_types) +# if env.action_mask[i] +# else ["none"], +# ) + +# # 7) Rewards/termination/truncation/info +# rewards = {agent_name: 0.0 for agent_name in env.agents} +# terminated = {agent_name: response.terminated for agent_name in env.agents} +# truncations = {agent_name: False for agent_name in env.agents} +# info = { +# agent_name: {"comments": response.comments or "", "complete_rating": 0} +# for agent_name in env.agents +# } +# return observations, rewards, terminated, truncations, info + + +# class SocialGameActionProcessor(PlainActionProcessor): +# """Extension point for social game state machines. + +# Override/extend hooks to implement per-state masking, visibility, and transitions. +# """ + +# def process( +# self, +# env: Any, +# actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], +# ) -> Tuple[ +# Dict[str, Observation], +# Dict[str, float], +# Dict[str, bool], +# Dict[str, bool], +# Dict[str, Dict[Any, Any]], +# ]: +# # Optionally apply pre-processing (e.g., state-based masking) here +# result = super().process(env, actions) +# # Optionally apply post-processing (e.g., state transitions, visibility logs) here +# # self._apply_state_transition(env, actions) # implement as needed +# return result diff --git a/sotopia/envs/evaluators.py b/sotopia/envs/evaluators.py index 4aeda1199..0c8d69ad0 100644 --- a/sotopia/envs/evaluators.py +++ b/sotopia/envs/evaluators.py @@ -1,7 +1,7 @@ import abc import logging from collections import defaultdict -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar import gin from pydantic import BaseModel, validate_call @@ -33,17 +33,55 @@ def __init__(self) -> None: @abc.abstractmethod def __call__( - self, turn_number: int, messages: list[tuple[str, Message]] + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: raise NotImplementedError @abc.abstractmethod async def __acall__( - self, turn_number: int, messages: list[tuple[str, Message]] + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: raise NotImplementedError +class SocialGameEndEvaluator(Evaluator): + """Base evaluator for social game win conditions. + + Subclasses should implement _check_win_conditions() to check + game-specific win conditions using the environment state. + """ + + def __init__(self, max_turn_number: int = 100) -> None: + self.max_turn_number = max_turn_number + + def __call__( + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any + ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: + # Check turn limit + if turn_number >= self.max_turn_number: + return [("environment", (("terminated", True), "Max turns reached"))] + + # Extract environment from kwargs + env = kwargs.get("env") + if not env: + return [("environment", (("terminated", False), ""))] + + # Check game-specific win conditions + terminated, reason = self._check_win_conditions(env, turn_number, messages) + return [("environment", (("terminated", terminated), reason))] + + async def __acall__( + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any + ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: + return self.__call__(turn_number, messages, **kwargs) + + def _check_win_conditions( + self, env: Any, turn_number: int, messages: list[tuple[str, Message]] + ) -> tuple[bool, str]: + """Check game-specific win conditions. Override in subclasses.""" + return False, "" + + class RuleBasedTerminatedEvaluator(Evaluator): def __init__(self, max_turn_number: int = 20, max_stale_turn: int = 2) -> None: self.max_turn_number = max_turn_number @@ -51,7 +89,7 @@ def __init__(self, max_turn_number: int = 20, max_stale_turn: int = 2) -> None: @validate_call def __call__( - self, turn_number: int, messages: list[tuple[str, Message]] + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: # Rule 1: If the conversation is too long, terminate the conversation conversation_too_long = turn_number >= self.max_turn_number @@ -109,9 +147,9 @@ def __call__( ] async def __acall__( - self, turn_number: int, messages: list[tuple[str, Message]] + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: - return self(turn_number, messages) + return self(turn_number, messages, **kwargs) class EpisodeLLMEvaluator(Evaluator, Generic[T_eval_dim]): @@ -125,7 +163,7 @@ def __init__( self.response_format_class = response_format_class def __call__( - self, turn_number: int, messages: list[tuple[str, Message]] + self, turn_number: int, messages: list[tuple[str, Message]], **kwargs: Any ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: raise NotImplementedError( "ReachGoalLLMEvaluator is not implemented for synchronous evaluation" @@ -139,6 +177,7 @@ async def __acall__( messages: list[tuple[str, Message]] | None, history: str = "", temperature: float | None = 0.0, + **kwargs: Any, ) -> list[tuple[str, tuple[tuple[str, int | float | bool], str]]]: # filter did nothing if not history and messages: diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index 6fb42ccff..8e3f34071 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -1,45 +1,30 @@ +"""Social game environment for multi-state games like Werewolves, Mafia, etc.""" + from __future__ import annotations import asyncio +import itertools import json +import random from pathlib import Path -from typing import Any, Dict, List, Literal +from typing import Any, Dict, List, Literal, cast -from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent +from sotopia.envs.parallel import ParallelSotopiaEnv +from sotopia.envs.evaluators import unweighted_aggregate_evaluate from sotopia.agents.llm_agent import Agents from sotopia.database import EnvironmentProfile -from sotopia.messages import AgentAction, Observation, Message, SimpleMessage - - -class GameMessage(Message): - """A message object with explicit recipients. - - recipients=None means public; otherwise only listed agents can view. - """ - - sender: str - content: str - state: str - recipients: list[str] | None = None - kind: Literal["speak", "action", "none"] = "none" - - def to_natural_language(self) -> str: - if self.kind == "speak": - return f'{self.sender} said: "{self.content}"' - elif self.kind == "action": - return f"{self.sender} [action] {self.content}" - elif self.kind == "none": - return f"{self.sender} did nothing" - else: - raise ValueError(f"Invalid message kind: {self.kind}") +from sotopia.messages import AgentAction, Observation, SimpleMessage class SocialGameEnv(ParallelSotopiaEnv): - """ - Core concepts: - - Per-state acting roles and action space - - Per-message visibility: public, team, private - - Per-agent message history is derived from the game message log + """Environment for social deduction games with states, roles, and private information. + + Adds to ParallelSotopiaEnv: + - FSM states (Night, Day, etc.) + - Role/team system + - Alive/dead status + - Private information visibility + - State transitions """ def __init__( @@ -50,56 +35,33 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(env_profile=env_profile, **kwargs) + + # Load game configuration self._config_path = Path(config_path) self._config: Dict[str, Any] = {} - self.role_to_team: Dict[ - str, str - ] = {} # Map roles to teams (e.g. Seer --> Villagers)) - self.agent_to_role: Dict[ - str, str - ] = {} # Map agents to roles (e.g. Aurora --> Villager) - self.agent_alive: Dict[ - str, bool - ] = {} # Map agents to their alive status (e.g. Aurora --> True (alive) or False (dead)) - - self.current_state: str = "" # Current state of the game (e.g. Day_discussion) - self.message_log: List[GameMessage] = [] # Log of all messages sent in the game - self.state_log: List[Dict[str, Any]] = [] # Log of all states in the game - self._state_transition: Dict[ - str, str - ] = {} # Map of state transitions (e.g. Night_witch --> Day_discussion) - self._state_props: Dict[ - str, Dict[str, Any] - ] = {} # Map of state properties (e.g. Night --> {acting_roles: ["Werewolf"], actions: ["speak", "action"], visibility: "team"}) - self.internal_state: Dict[ - str, Any - ] = {} # Internal state of the game (e.g. votes, night_target, witch_save, witch_poison) - - # ----------------------------- - # Config loading - # ----------------------------- + # Game state + self.current_state: str = "" + self.agent_to_role: Dict[str, str] = {} # Aurora -> Villager + self.role_to_team: Dict[str, str] = {} # Villager -> Villagers + self.agent_alive: Dict[str, bool] = {} # Aurora -> True + self.internal_state: Dict[str, Any] = {} # votes, targets, etc. + def _load_config(self) -> None: - # Read config and normalize to FSM structures + """Load game configuration from JSON file.""" if not self._config_path.exists(): - raise FileNotFoundError(f"config_path does not exist: {self._config_path}") + raise FileNotFoundError(f"Config not found: {self._config_path}") + self._config = json.loads(self._config_path.read_text()) - # Build role_to_team from agents if available + # Build role -> team mapping self.role_to_team = {} - for agent in self._config.get("agents", []): - role = agent.get("role") - team = agent.get("team") - if isinstance(role, str) and isinstance(team, str): + for agent_entry in self._config.get("agents", []): + role = agent_entry.get("role") + team = agent_entry.get("team") + if role and team: self.role_to_team.setdefault(role, team) - # FSM structures - self._state_transition = dict(self._config.get("state_transition", {})) - self._state_props = dict(self._config.get("state_properties", {})) - - # ----------------------------- - # Lifecycle - # ----------------------------- def reset( self, seed: int | None = None, @@ -108,154 +70,55 @@ def reset( omniscient: bool = False, lite: bool = False, ) -> Dict[str, Observation]: + """Reset environment and initialize game state.""" + # Call parent reset base_obs = super().reset( seed=seed, options=options, agents=agents, omniscient=omniscient, lite=lite ) + # Load config self._load_config() + # Map agent names to roles from config self.agent_to_role = {} for name in self.agents: role = next( ( a.get("role", "Villager") for a in self._config.get("agents", []) - if a.get("name") == name + if a.get("name") == name.strip() ), "Villager", ) self.agent_to_role[name] = role + # Initialize alive status and state self.agent_alive = {name: True for name in self.agents} - self.current_state = self._config.get("initial_state", "Night") - self.message_log = [] - self.state_log = [] + self.current_state = self._config.get("initial_state", "Day_discussion") self.internal_state = {} - init_spec = self._state_spec(self.current_state) - if isinstance(init_spec.get("internal_state"), dict): - self.internal_state.update(init_spec["internal_state"]) - - # Prepare first observation - self._apply_action_mask() - self._append_system_message(f"[Game] State: {self.current_state}") - return self._build_observations(base_obs, append_to_existing=True) - - # ----------------------------- - # Core helpers - # ----------------------------- - def _apply_action_mask(self) -> None: - # Determine eligible actors by role and alive status - eligible = [ - n - for n in self.agents - if self.agent_alive.get(n, True) - and ( - not set(self._state_spec(self.current_state).get("acting_roles", [])) - or self.agent_to_role.get(n, "") - in set(self._state_spec(self.current_state).get("acting_roles", [])) - ) - ] - - # Order policy from config: "round-robin" (default) or "simultaneous" - order = str(self._state_spec(self.current_state).get("order", "round-robin")) - if order == "round-robin": - mask = [False for _ in self.agents] - if eligible: - idx = self.turn_number % len(eligible) - current_actor = eligible[idx] - for i, name in enumerate(self.agents): - mask[i] = name == current_actor - self.action_mask = mask - return - - # Default: simultaneous for all eligible actors - eligible_set = set(eligible) - self.action_mask = [name in eligible_set for name in self.agents] - - def _state_spec(self, state: str) -> Dict[str, Any]: - return dict(self._state_props.get(state, {})) - - def _allowed_actions_for_role(self, state: str, role: str) -> List[str]: - spec = self._state_spec(state) - allowed = list(spec.get("actions", ["none"])) - if "none" not in allowed: - allowed.append("none") - acting_roles = spec.get("acting_roles") - if acting_roles and role not in set(acting_roles): - return ["none"] - return allowed - def available_actions(self, agent_name: str) -> List[str]: - if not self.agent_alive.get(agent_name, True): - return ["none"] - role = self.agent_to_role.get(agent_name, "Villager") - return self._allowed_actions_for_role(self.current_state, role) - - def active_agents_for_state(self) -> List[str]: - acting_roles = set(self._state_spec(self.current_state).get("acting_roles", [])) - return [ - n - for n in self.agents - if self.agent_alive.get(n, True) - and (not acting_roles or self.agent_to_role.get(n, "") in acting_roles) - ] - - def _append_system_message(self, text: str) -> None: - self.message_log.append( - GameMessage( - sender="Environment", - content=text, - state=self.current_state, - recipients=None, - ) + # Send initial system message + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Game] State: {self.current_state}. The game begins!" + ), ) - def _can_view(self, agent_name: str, m: GameMessage) -> bool: - return m.recipients is None or agent_name in (m.recipients or []) + # Initialize round-robin counter + self._round_robin_idx = 0 - def _visible_text(self, agent_name: str) -> str: - return "\n".join( - m.to_natural_language() - for m in self.message_log - if self._can_view(agent_name, m) - ) + # Initialize action mask for first turn based on state + self._update_action_mask() - def _build_observations( - self, baseline: Dict[str, Observation], *, append_to_existing: bool - ) -> Dict[str, Observation]: - acting = set(self.active_agents_for_state()) - new_obs: Dict[str, Observation] = {} - for idx, name in enumerate(self.agents): - current = baseline[name] - available = self.available_actions(name) if name in acting else ["none"] - - lines: List[str] = [] - if append_to_existing and current.last_turn.strip(): - lines.append(current.last_turn.strip()) - lines.append(f"[Game] State: {self.current_state}") - lines.append( - "[Game] It is your turn to act." - if name in acting - else "[Game] You are observing this state." + # Update available actions based on game state + for agent_name in self.agents: + base_obs[agent_name].available_actions = self._get_available_actions( + agent_name ) - lines.append(f"[Game] Available actions: {', '.join(available)}") - visible = self._visible_text(name) - if visible: - lines.append(visible) - else: - lines.append("[Game] Await instructions from the host.") - combined = "\n".join(seg for seg in lines if seg) - new_obs[name] = Observation( - last_turn=render_text_for_agent(combined, agent_id=idx), - turn_number=current.turn_number, - available_actions=available, - ) - return new_obs + return base_obs - # ----------------------------- - # Step - # ----------------------------- async def astep( self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] ) -> tuple[ @@ -265,100 +128,341 @@ async def astep( Dict[str, bool], Dict[str, Dict[Any, Any]], ]: - self._apply_action_mask() + """Process one step: record actions, update state, build observations.""" self.turn_number += 1 - # Normalize actions - prepared: Dict[str, AgentAction] = {} - for agent, raw in actions.items(): - if isinstance(raw, AgentAction): - prepared[agent] = raw + # 1. Normalize actions to AgentAction objects + normalized_actions: Dict[str, AgentAction] = {} + for agent_name, action in actions.items(): + if isinstance(action, AgentAction): + normalized_actions[agent_name] = action else: - idx = int(raw.get("action_type", 0)) - action_type = self.available_action_types[idx] - prepared[agent] = AgentAction( - action_type=action_type, argument=str(raw.get("argument", "")) + # Convert dict to AgentAction + action_type = self.available_action_types[ + int(action.get("action_type", 0)) + ] + normalized_actions[agent_name] = AgentAction( + action_type=action_type, + argument=str(action.get("argument", "")), ) + # 2. Record actions to message history self.recv_message( "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") ) - for agent, action in prepared.items(): - self.recv_message(agent, action) - - acting = set(self.active_agents_for_state()) - recorded_msgs: List[GameMessage] = [] - - # Record messages based on visibility rules - for actor in acting: - act = prepared.get(actor) - if not act or act.action_type == "none": - continue - if act.action_type == "speak": - gm = GameMessage( - sender=actor, - content=act.argument.strip(), - state=self.current_state, - recipients=None, # default public; make explicit via config if needed - kind="speak", + for idx, (agent_name, action) in enumerate(normalized_actions.items()): + # Only record actions from agents who were allowed to act + if self.agent_alive.get(agent_name, False) and self.action_mask[idx]: + self.recv_message(agent_name, action) + + # 3. Run evaluators to check if game should terminate (e.g., max turns) + evaluator_response = unweighted_aggregate_evaluate( + list( + itertools.chain( + *await asyncio.gather( + *[ + evaluator.__acall__( + turn_number=self.turn_number, + messages=self.inbox, + env=self, + ) + for evaluator in self.evaluators + ] + ) ) - if gm.content: - self.message_log.append(gm) - recorded_msgs.append(gm) - elif act.action_type == "action": - gm = GameMessage( - sender=actor, - content=act.argument.strip(), - state=self.current_state, - recipients=None, # default public; make explicit via config if needed - kind="action", + ) + ) + + # 4. Process game-specific logic + self._process_actions(normalized_actions) + + # 5. Check for eliminations + self._check_eliminations() + + # 6. Check if state should transition + should_transition = self._should_transition_state() + print( + f"DEBUG Turn {self.turn_number}: state={self.current_state}, should_transition={should_transition}, state_turn_count={getattr(self, '_state_turn_count', {})}" + ) + if should_transition: + self._transition_state() + print(f"DEBUG: Transitioned to {self.current_state}") + + # 7. Update action mask for next turn based on state + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} + ) + action_order = state_props.get("action_order", self.action_order) + print( + f"DEBUG: About to update mask - state={self.current_state}, action_order={action_order}" + ) + self._update_action_mask() + print(f"DEBUG: After update_action_mask - mask={self.action_mask}") + + # 8. Build observations with visibility filtering + observations = self._build_observations() + + # 9. Set termination from evaluators (including game-specific win conditions) + terminated = {agent: evaluator_response.terminated for agent in self.agents} + + # 10. If terminated and terminal_evaluators exist, run them + if evaluator_response.terminated and self.terminal_evaluators: + terminal_response = unweighted_aggregate_evaluate( + list( + itertools.chain( + *await asyncio.gather( + *[ + evaluator.__acall__( + turn_number=self.turn_number, + messages=self.inbox, + env=self, + ) + for evaluator in self.terminal_evaluators + ] + ) + ) ) - if gm.content: - self.message_log.append(gm) - recorded_msgs.append(gm) + ) + # Merge terminal evaluator response + if evaluator_response.comments and terminal_response.comments: + evaluator_response.comments += terminal_response.comments + elif terminal_response.comments: + evaluator_response.comments = terminal_response.comments + + rewards = {agent: 0.0 for agent in self.agents} + truncations = {agent: False for agent in self.agents} + info = { + agent: {"comments": evaluator_response.comments or "", "complete_rating": 0} + for agent in self.agents + } - # State advancement - self.current_state = self._state_transition.get( - self.current_state, self.current_state + return observations, rewards, terminated, truncations, info + + def _process_actions(self, actions: Dict[str, AgentAction]) -> None: + """Process actions based on current state (votes, kills, etc.).""" + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} ) - state_internal = self._state_spec(self.current_state).get("internal_state") - if isinstance(state_internal, dict): - self.internal_state.update(state_internal) - self._append_system_message(f"[Game] State: {self.current_state}") - - # Append to state_log for external summarization if needed - self.state_log.append( - { - "state": self.current_state, - "public": [ - m.to_natural_language() - for m in self.message_log - if m.recipients is None and m.state == self.current_state - ], - } + + # Example: collect votes in voting state + if "vote" in state_props.get("actions", []): + for agent_name, action in actions.items(): + if action.action_type == "action" and "vote" in action.argument.lower(): + # Parse vote target from argument + # Store in internal_state + pass + + def _check_eliminations(self) -> None: + """Check if anyone should be eliminated (voted out, killed, etc.).""" + # Example: tally votes and eliminate player with most votes + pass + + def _update_action_mask(self) -> None: + """Update action mask for next turn based on state configuration.""" + # Get action_order for this state from config, or use environment default + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} ) + action_order = state_props.get("action_order", self.action_order) + acting_roles = state_props.get("acting_roles", []) + + # Determine which agents are eligible to act in this state + if acting_roles: + # Only agents with specific roles can act + eligible_indices = [ + idx + for idx, agent_name in enumerate(self.agents) + if self.agent_alive.get(agent_name, False) + and self.agent_to_role.get(agent_name, "") in acting_roles + ] + else: + # All alive agents can act + eligible_indices = [ + idx + for idx, agent_name in enumerate(self.agents) + if self.agent_alive.get(agent_name, False) + ] + + # Update action mask based on action order + self.action_mask = [False for _ in self.agents] + + if not eligible_indices: + # No eligible agents - keep all masks False + return + + if action_order == "round-robin": + # Cycle through eligible agents only + if not hasattr(self, "_round_robin_idx"): + self._round_robin_idx = 0 + # Get next eligible agent + acting_idx = eligible_indices[self._round_robin_idx % len(eligible_indices)] + self.action_mask[acting_idx] = True + self._round_robin_idx += 1 + elif action_order == "random": + # Pick random eligible agent + acting_idx = random.choice(eligible_indices) + self.action_mask[acting_idx] = True + else: + # Simultaneous: all eligible agents can act + for idx in eligible_indices: + self.action_mask[idx] = True + + def _should_transition_state(self) -> bool: + """Check if we should move to next state based on how many agents have acted.""" + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} + ) + acting_roles = state_props.get("acting_roles", []) + action_order = state_props.get("action_order", self.action_order) + + # Initialize turn counter for this state if needed + if not hasattr(self, "_state_turn_count"): + self._state_turn_count: Dict[str, int] = {} + if self.current_state not in self._state_turn_count: + self._state_turn_count[self.current_state] = 0 + + # Increment turn count for this state + self._state_turn_count[self.current_state] += 1 + turns_in_state = self._state_turn_count[self.current_state] + + # Determine how many agents should act in this state + if acting_roles: + # Only specific roles act - count them + num_acting_agents = sum( + 1 + for agent in self.agents + if self.agent_alive.get(agent, False) + and self.agent_to_role.get(agent, "") in acting_roles + ) + else: + # All alive agents act + num_acting_agents = sum(1 for alive in self.agent_alive.values() if alive) + + # Transition logic based on action order + if action_order == "simultaneous": + # All agents act at once - transition after 1 turn + return turns_in_state >= 1 + elif action_order in ["round-robin", "random"]: + # Each agent acts once - transition after N turns + return turns_in_state >= num_acting_agents + + return False + + def _transition_state(self) -> None: + """Transition to next state based on FSM.""" + state_transition = self._config.get("state_transition", {}) + next_state = state_transition.get(self.current_state) + + if next_state: + self.current_state = next_state + # Reset turn counter for the new state + if hasattr(self, "_state_turn_count"): + self._state_turn_count[self.current_state] = 0 + # Reset round-robin counter for the new state + if hasattr(self, "_round_robin_idx"): + self._round_robin_idx = 0 + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Game] Transitioning to state: {self.current_state}" + ), + ) + + def _build_observations(self) -> Dict[str, Observation]: + """Build observations for each agent based on visibility rules.""" + observations = {} + + for i, agent_name in enumerate(self.agents): + # Get recent messages visible to this agent + visible_history = self._get_visible_history(agent_name) - self._apply_action_mask() - baseline = { - name: Observation( - last_turn="", turn_number=self.turn_number, available_actions=["none"] + # Get available actions for this agent + available_actions = self._get_available_actions(agent_name) + + observations[agent_name] = Observation( + last_turn=visible_history, + turn_number=self.turn_number, + available_actions=available_actions, ) - for name in self.agents + + return observations + + def _get_visible_history(self, agent_name: str) -> str: + """Get message history visible to this agent based on visibility rules.""" + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} + ) + visibility = state_props.get("visibility", "public") + + visible_messages = [] + + for sender, message in self.inbox[-10:]: # Last 10 messages + if sender == "Environment": + # Environment messages always visible + visible_messages.append(message.to_natural_language()) + elif visibility == "public": + # Public: everyone sees everything + visible_messages.append(f"{sender}: {message.to_natural_language()}") + elif visibility == "team": + # Team: only see teammate messages + sender_team = self.role_to_team.get( + self.agent_to_role.get(sender, ""), "" + ) + viewer_team = self.role_to_team.get( + self.agent_to_role.get(agent_name, ""), "" + ) + if sender_team == viewer_team: + visible_messages.append( + f"{sender}: {message.to_natural_language()}" + ) + elif visibility == "private": + # Private: only see own messages + if sender == agent_name: + visible_messages.append(f"You: {message.to_natural_language()}") + + return ( + "\n".join(visible_messages) if visible_messages else "[No recent activity]" + ) + + def _get_available_actions( + self, agent_name: str + ) -> List[Literal["none", "speak", "non-verbal communication", "action", "leave"]]: + """Get available actions for this agent based on state and role, restricted to allowed literals.""" + if not self.agent_alive.get(agent_name, False): + return ["none"] + + state_props = self._config.get("state_properties", {}).get( + self.current_state, {} + ) + acting_roles = state_props.get("acting_roles", []) + actions = state_props.get("actions", ["speak"]) + + # If state restricts by role, check if this agent can act + if acting_roles: + agent_role = self.agent_to_role.get(agent_name, "") + if agent_role not in acting_roles: + return ["none"] + + allowed = { + "none", + "speak", + "non-verbal communication", + "action", + "leave", } - observations = self._build_observations(baseline, append_to_existing=False) - rewards = {a: 0.0 for a in self.agents} - terminated = {a: False for a in self.agents} - truncations = {a: False for a in self.agents} - info = {a: {"comments": "", "complete_rating": 0} for a in self.agents} - return observations, rewards, terminated, truncations, info + filtered = [a for a in actions if a in allowed] or ["none"] + return cast( + List[ + Literal["none", "speak", "non-verbal communication", "action", "leave"] + ], + filtered, + ) - def step( - self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] - ) -> tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - return asyncio.run(self.astep(actions)) + def get_agent_role(self, agent_name: str) -> str: + """Get the role of an agent.""" + return self.agent_to_role.get(agent_name, "Unknown") + + def get_agent_team(self, agent_name: str) -> str: + """Get the team of an agent.""" + role = self.get_agent_role(agent_name) + return self.role_to_team.get(role, "Unknown") diff --git a/sotopia/envs/social_game_legacy.py b/sotopia/envs/social_game_legacy.py deleted file mode 100644 index 584b39b61..000000000 --- a/sotopia/envs/social_game_legacy.py +++ /dev/null @@ -1,464 +0,0 @@ -"""Minimal social game environment for phase-based multi-agent games like Werewolf.""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from pydantic import BaseModel - -from sotopia.agents.llm_agent import Agents -from sotopia.database import EnvironmentProfile -from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent -from sotopia.messages import AgentAction, Observation, SimpleMessage - -__all__ = [ - "GameRules", - "SocialGame", - "SocialGameEnv", - "RoleConfig", - "PhaseConfig", - "GameState", -] - - -# ============================================================================ -# Configuration Models -# ============================================================================ - - -class RoleConfig(BaseModel): - """Role definition: team, actions per phase.""" - - team: str - actions: dict[str, list[str]] # phase -> allowed actions - goal: str = "" - - -class PhaseConfig(BaseModel): - """Phase definition: who acts, visibility, resolution.""" - - actors: list[str] | None = None # roles that act (None = all alive) - visibility: str = "public" # public|team|private - resolution: str = "none" # none|kill|vote|inspect|witch|announce_deaths - next_phase: str = "" - - -class GameRules(BaseModel): - """Complete game configuration.""" - - roles: dict[str, RoleConfig] - phases: dict[str, PhaseConfig] - start_phase: str - win_conditions: list[dict[str, str]] # [{type: "eliminate", team: "Werewolves"}] - - -# ============================================================================ -# Game State -# ============================================================================ - - -@dataclass -class GameState: - """Runtime game state.""" - - agents: dict[str, dict[str, Any]] = field( - default_factory=dict - ) # name -> {role, team, alive, ...} - phase: str = "" - state: dict[str, Any] = field( - default_factory=dict - ) # shared state (targets, votes, etc) - messages: list[str] = field(default_factory=list) # pending public messages - team_messages: dict[str, list[str]] = field( - default_factory=dict - ) # team -> messages - private_messages: dict[str, list[str]] = field( - default_factory=dict - ) # agent -> messages - - def alive(self) -> list[str]: - return [name for name, info in self.agents.items() if info["alive"]] - - def by_team(self, team: str) -> list[str]: - return [ - name - for name, info in self.agents.items() - if info["team"] == team and info["alive"] - ] - - def add_msg( - self, msg: str, visibility: str = "public", target: str | None = None - ) -> None: - """Add message with visibility control.""" - if visibility == "public": - self.messages.append(msg) - elif visibility == "team" and target: - self.team_messages.setdefault(target, []).append(msg) - elif visibility == "private" and target: - self.private_messages.setdefault(target, []).append(msg) - - def flush_messages( - self, - ) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]: - """Return and clear all messages.""" - pub, team, priv = ( - self.messages[:], - dict(self.team_messages), - dict(self.private_messages), - ) - self.messages.clear() - self.team_messages.clear() - self.private_messages.clear() - return pub, team, priv - - -# ============================================================================ -# Game Engine -# ============================================================================ - - -class SocialGame: - """Phase-based social game engine.""" - - def __init__(self, rules: GameRules): - self.rules = rules - self.state = GameState() - - def init(self, agents: list[str], roles: dict[str, str]) -> None: - """Initialize game with agent-role assignments.""" - self.state.agents = { - name: { - "role": roles[name], - "team": self.rules.roles[roles[name]].team, - "alive": True, - "attrs": {}, # role-specific state - } - for name in agents - } - self.state.phase = self.rules.start_phase - self.state.state = { - "votes": {}, - "night_target": None, - "witch_save": False, - "witch_poison": None, - } - # Witch tracking - for name, info in self.state.agents.items(): - if info["role"] == "Witch": - info["attrs"]["save_used"] = False - info["attrs"]["poison_used"] = False - - def active_agents(self) -> list[str]: - """Who can act in current phase?""" - phase_cfg = self.rules.phases[self.state.phase] - if not phase_cfg.actors: - return self.state.alive() - return [ - n - for n in self.state.alive() - if self.state.agents[n]["role"] in phase_cfg.actors - ] - - def available_actions(self, agent: str) -> list[str]: - """What actions can agent take?""" - if not self.state.agents[agent]["alive"]: - return ["none"] - role = self.state.agents[agent]["role"] - phase = self.state.phase - return self.rules.roles[role].actions.get(phase, ["none"]) - - def process_turn( - self, actions: dict[str, AgentAction] - ) -> tuple[bool, dict[str, str] | None]: - """Process actions, run resolution, return (phase_done, winner).""" - phase_cfg = self.rules.phases[self.state.phase] - - # Record speech - for agent, action in actions.items(): - if ( - action.action_type in ["speak", "non-verbal communication"] - and action.argument.strip() - ): - msg = f"{agent}: {action.argument}" - vis = phase_cfg.visibility - target = ( - self.state.agents[agent]["team"] - if vis == "team" - else agent - if vis == "private" - else None - ) - self.state.add_msg(msg, vis, target) - - # Resolve phase - self._resolve(phase_cfg.resolution, actions) - - # Check win - winner = self._check_win() - - # Advance phase - self.state.phase = phase_cfg.next_phase - - return True, winner - - def _resolve(self, resolution: str, actions: dict[str, AgentAction]) -> None: - """Execute phase resolution logic.""" - if resolution == "none": - return - - if resolution == "kill": - # Werewolves pick target - target = self._extract_name(actions) - if target: - self.state.state["night_target"] = target - team = self.state.agents[list(actions.keys())[0]]["team"] - self.state.add_msg(f"Target: {target}", "team", team) - - elif resolution == "inspect": - # Seer inspects - target = self._extract_name(actions) - if target and actions: - agent = list(actions.keys())[0] - team = self.state.agents[target]["team"] - self.state.add_msg(f"{target} is on team {team}", "private", agent) - - elif resolution == "witch": - # Witch save/poison - if not actions: - return - agent = list(actions.keys())[0] - arg = list(actions.values())[0].argument.lower() - - if "save" in arg and not self.state.agents[agent]["attrs"]["save_used"]: - target = self.state.state.get("night_target") - if target: - self.state.state["witch_save"] = True - self.state.agents[agent]["attrs"]["save_used"] = True - self.state.add_msg(f"You saved {target}", "private", agent) - - if "poison" in arg and not self.state.agents[agent]["attrs"]["poison_used"]: - target = self._extract_name(actions) - if target: - self.state.state["witch_poison"] = target - self.state.agents[agent]["attrs"]["poison_used"] = True - self.state.add_msg(f"You poisoned {target}", "private", agent) - - elif resolution == "announce_deaths": - # Resolve night kills - killed = [] - target = self.state.state.get("night_target") - if target and not self.state.state.get("witch_save"): - killed.append(target) - poison = self.state.state.get("witch_poison") - if poison and poison not in killed: - killed.append(poison) - - if killed: - for victim in killed: - self.state.agents[victim]["alive"] = False - self.state.add_msg(f"{victim} died") - else: - self.state.add_msg("No one died") - - # Reset night state - self.state.state.update( - {"night_target": None, "witch_save": False, "witch_poison": None} - ) - - elif resolution == "vote": - # Tally votes - votes: dict[str, int] = {} - for action in actions.values(): - target = self._extract_name({0: action}) - if target: - votes[target] = votes.get(target, 0) + 1 - - if votes: - winner = max(votes, key=votes.get) # type: ignore - max_votes = votes[winner] - # Check tie - if list(votes.values()).count(max_votes) == 1: - self.state.agents[winner]["alive"] = False - team = self.state.agents[winner]["team"] - self.state.add_msg(f"Voted out: {winner} (team {team})") - else: - self.state.add_msg("Vote tied, no execution") - else: - self.state.add_msg("No valid votes") - - def _extract_name(self, actions: dict[Any, AgentAction]) -> str | None: - """Extract target name from action arguments.""" - for action in actions.values(): - text = f"{action.action_type} {action.argument}".lower() - for name in self.state.agents: - if name.lower() in text or name.split()[0].lower() in text: - return name - return None - - def _check_win(self) -> dict[str, str] | None: - """Check win conditions.""" - for cond in self.rules.win_conditions: - if cond["type"] == "eliminate": - team = cond["team"] - if not self.state.by_team(team): - return { - "winner": cond.get("winner", "Other"), - "message": f"Team {team} eliminated", - } - elif cond["type"] == "parity": - team1 = self.state.by_team(cond["team"]) - team2 = self.state.by_team(cond["other"]) - if len(team1) >= len(team2): - return { - "winner": cond["team"], - "message": f"{cond['team']} reached parity", - } - return None - - -# ============================================================================ -# Environment Wrapper -# ============================================================================ - - -class SocialGameEnv(ParallelSotopiaEnv): - """Sotopia environment for social games.""" - - def __init__( - self, - env_profile: EnvironmentProfile, - *, - rules_path: str, - role_assignments: dict[str, str], - **kwargs: Any, - ): - super().__init__(env_profile=env_profile, **kwargs) - self.rules_path = Path(rules_path) - self.role_assignments = role_assignments - self.game: SocialGame | None = None - - def reset( - self, - seed: int | None = None, - options: dict[str, str] | None = None, - agents: Agents | None = None, - omniscient: bool = False, - lite: bool = False, - ) -> dict[str, Observation]: - obs = super().reset(seed, options, agents, omniscient, lite) - - # Load rules and init game - rules = GameRules.model_validate_json(self.rules_path.read_text()) - self.game = SocialGame(rules) - self.game.init(self.agents, self.role_assignments) - - return self._build_observations(obs) - - def _build_observations( - self, base_obs: dict[str, Observation] - ) -> dict[str, Observation]: - """Build observations with game state.""" - assert self.game is not None - - pub, team, priv = self.game.state.flush_messages() - active = set(self.game.active_agents()) - - new_obs = {} - for idx, agent in enumerate(self.agents): - actions = self.game.available_actions(agent) - - # Collect visible messages - msgs = pub[:] - agent_team = self.game.state.agents[agent]["team"] - msgs.extend(team.get(agent_team, [])) - msgs.extend(priv.get(agent, [])) - - # Build prompt - role = self.game.state.agents[agent]["role"] - phase = self.game.state.phase - lines = [ - f"Phase: {phase}", - f"Role: {role}", - f"Actions: {', '.join(actions)}", - f"Active: {'Yes' if agent in active else 'No'}", - "", - *msgs, - ] - - new_obs[agent] = Observation( - last_turn=render_text_for_agent("\n".join(lines), idx), - turn_number=self.turn_number, - available_actions=actions, - ) - - return new_obs - - async def astep( - self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] - ) -> tuple[ - dict[str, Observation], - dict[str, float], - dict[str, bool], - dict[str, bool], - dict[str, dict[Any, Any]], - ]: - assert self.game is not None - - self.turn_number += 1 - - # Convert actions - converted = {} - for agent, action in actions.items(): - if isinstance(action, AgentAction): - converted[agent] = action - else: - act_type = self.available_action_types[ - int(action.get("action_type", 0)) - ] - converted[agent] = AgentAction( - action_type=act_type, argument=str(action.get("argument", "")) - ) - - # Log - self.recv_message( - "Environment", SimpleMessage(message=f"Turn {self.turn_number}") - ) - for agent, action in converted.items(): - self.recv_message(agent, action) - - # Process - _, winner = self.game.process_turn(converted) - - # Build observations - base_obs = { - agent: Observation( - last_turn="", turn_number=self.turn_number, available_actions=["none"] - ) - for agent in self.agents - } - obs = self._build_observations(base_obs) - - # Results - rewards = {a: 0.0 for a in self.agents} - terminated = {a: bool(winner) for a in self.agents} - truncated = {a: False for a in self.agents} - info = { - a: {"comments": winner["message"] if winner else "", "complete_rating": 0} - for a in self.agents - } - - return obs, rewards, terminated, truncated, info - - def step( - self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] - ) -> tuple[ - dict[str, Observation], - dict[str, float], - dict[str, bool], - dict[str, bool], - dict[str, dict[Any, Any]], - ]: - return asyncio.run(self.astep(actions)) diff --git a/sotopia/envs/social_game_legacy2.py b/sotopia/envs/social_game_legacy2.py deleted file mode 100644 index d2652bc1a..000000000 --- a/sotopia/envs/social_game_legacy2.py +++ /dev/null @@ -1,656 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence - -from sotopia.envs.parallel import ParallelSotopiaEnv, render_text_for_agent -from sotopia.agents.llm_agent import Agents -from sotopia.database import EnvironmentProfile -from sotopia.messages import AgentAction, Observation, SimpleMessage - - -# ----------------------------- -# Data containers -# ----------------------------- - - -@dataclass -class AgentState: - """Runtime state for an agent: identity, role/team, alive flag and extras.""" - - name: str - role: str - team: str - alive: bool = True - attributes: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class Events: - """Aggregated event streams for a phase step (public/team/private).""" - - public: List[str] = field(default_factory=list) - team: Dict[str, List[str]] = field(default_factory=dict) - private: Dict[str, List[str]] = field(default_factory=dict) - - def extend(self, other: "Events") -> None: - self.public.extend(other.public) - for k, v in other.team.items(): - self.team.setdefault(k, []).extend(v) - for k, v in other.private.items(): - self.private.setdefault(k, []).extend(v) - - -@dataclass -class SimpleRules: - """Parsed rulebook config. - - example JSON shape: - { - "initial_phase": "night", - "next_phase": {"night": "day", "day": "night"}, - "phases": { - "night": { - "acting_roles": ["Werewolf", "Seer", "Witch"], - "speech_visibility": {"Werewolf": "team", "Seer": "private", "Witch": "private"}, - "resolvers": [ {"op": "store_target", ...}, ... ] - }, - "day": { ... } - }, - "end_conditions": [ ... ] - } - """ - - initial_phase: str - next_phase: Dict[str, str] - phases: Dict[str, Dict[str, Any]] - end_conditions: List[Dict[str, Any]] - - -@dataclass -class SimpleActions: - """Parsed action-space config. - - example JSON shape: - { - "teams": {"Villager": "Villagers", "Werewolf": "Werewolves", "Seer": "Villagers",...}, - "phase_actions": {"night": {"Werewolf": ["speak","action"], "Villager": ["none"]}, "day": {"*": ["speak","action"]}}, - "initial_state": {"Witch": {"save_available": true, "poison_available": true}} - } - """ - - role_to_team: Dict[str, str] - phase_actions: Dict[str, Dict[str, List[str]]] - initial_state: Dict[str, Dict[str, Any]] = field(default_factory=dict) - - -# ----------------------------- -# Environment -# ----------------------------- - - -class SocialGameEnv(ParallelSotopiaEnv): - """Social game environment - - Speech routing is driven by per-phase visibility rules - - Available actions come from phase x role mapping - - End-of-phase effects are executed by generic, JSON-declared resolvers - """ - - def __init__( - self, - env_profile: EnvironmentProfile, - *, - rulebook_path: str, - actions_path: str, - role_assignments: Dict[str, str], - **kwargs: Any, - ) -> None: - super().__init__(env_profile=env_profile, **kwargs) - self._rulebook_path = Path(rulebook_path) - self._actions_path = Path(actions_path) - self._role_assignments = role_assignments - - self.rules: Optional[SimpleRules] = None - self.actions: Optional[SimpleActions] = None - self.agent_states: Dict[str, AgentState] = {} - self.current_phase: str = "" - self._last_events: Events = Events() - self._winner_payload: Optional[Dict[str, str]] = None - - # Ephemeral named values for cross-resolver coordination - self.state_flags: Dict[str, Any] = {} - - # ----------------------------- - # Config loading - # ----------------------------- - def _load_configs(self) -> None: - rules_raw = json.loads(self._rulebook_path.read_text()) - actions_raw = json.loads(self._actions_path.read_text()) - - phases = rules_raw.get("phases") or {} - self.rules = SimpleRules( - initial_phase=rules_raw.get("initial_phase", ""), - next_phase=rules_raw.get("next_phase", {}), - phases=phases, - end_conditions=rules_raw.get("end_conditions", []), - ) - - self.actions = SimpleActions( - role_to_team=actions_raw.get("teams", {}), - phase_actions=actions_raw.get("phase_actions", {}), - initial_state=actions_raw.get("initial_state", {}), - ) - - # ----------------------------- - # Lifecycle - # ----------------------------- - def reset( - self, - seed: int | None = None, - options: Dict[str, str] | None = None, - agents: Agents | None = None, - omniscient: bool = False, - lite: bool = False, - ) -> Dict[str, Observation]: - """Reset the environment""" - base_obs = super().reset( - seed=seed, options=options, agents=agents, omniscient=omniscient, lite=lite - ) - - self._load_configs() - assert self.rules is not None and self.actions is not None - - # Assign agents - self.agent_states.clear() - for name in self.agents: - role = self._role_for_agent(name) - team = self.actions.role_to_team.get(role, "") - attrs = dict(self.actions.initial_state.get(role, {})) - self.agent_states[name] = AgentState( - name=name, role=role, team=team, attributes=attrs - ) - - self.current_phase = self.rules.initial_phase - self._winner_payload = None - self.state_flags = {} - - self._apply_action_mask() - self._last_events = Events( - public=[f"[God] Phase: {self.current_phase.title()}"] - ) - return self._augment_observations(base_obs, append_to_existing=True) - - # ----------------------------- - # Helpers - # ----------------------------- - def _role_for_agent(self, agent_name: str) -> str: - return self._role_assignments.get(agent_name, agent_name) - - def _phase_conf(self, phase: str) -> Dict[str, Any]: - assert self.rules is not None - return self.rules.phases.get(phase, {}) - - def _speech_visibility_for(self, phase: str, role: str) -> str: - conf = self._phase_conf(phase) - vis = conf.get("speech_visibility", {}) - return vis.get(role, vis.get("*", "public")) - - def _phase_actions_for_role(self, phase: str, role: str) -> List[str]: - assert self.actions is not None - layer = self.actions.phase_actions.get(phase, {}) - base = layer.get(role, layer.get("*", ["none"])) - if "none" not in base: - return list(base) + ["none"] - return list(base) - - def _eligible_actors(self, phase: str) -> List[str]: - conf = self._phase_conf(phase) - acting_roles = set(conf.get("acting_roles", [])) - acting_teams = set(conf.get("acting_teams", [])) - alive = [n for n, s in self.agent_states.items() if s.alive] - eligible = [] - for n in alive: - st = self.agent_states[n] - if acting_roles and st.role not in acting_roles: - continue - if acting_teams and st.team not in acting_teams: - continue - # require that role has any action other than none - if self._phase_actions_for_role(phase, st.role) != ["none"]: - eligible.append(n) - return eligible - - def active_agents_for_phase(self) -> List[str]: - return self._eligible_actors(self.current_phase) - - def available_actions(self, agent_name: str) -> List[str]: - if not self.agent_states[agent_name].alive: - return ["none"] - role = self.agent_states[agent_name].role - return self._phase_actions_for_role(self.current_phase, role) - - def _apply_action_mask(self) -> None: - acting = set(self.active_agents_for_phase()) - self.action_mask = [ - a in acting and self.agent_states[a].alive for a in self.agents - ] - - def _record_speech(self, actor: str, action: AgentAction) -> Events: - events = Events() - if action.action_type != "speak": - return events - text = action.argument.strip() - if not text: - return events - line = f'{actor} said: "{text}"' - vis = self._speech_visibility_for( - self.current_phase, self.agent_states[actor].role - ) - if vis == "team": - team = self.agent_states[actor].team - events.team.setdefault(team, []).append(line) - elif vis == "private": - events.private.setdefault(actor, []).append(line) - elif vis == "hidden": - return events - else: - events.public.append(line) - return events - - def _extract_target_from_text(self, text: str) -> Optional[str]: - corpus = text.lower() - for name in self.agent_states: - if name.lower() in corpus: - return name - for name in self.agent_states: - first = name.split()[0].lower() - if first in corpus: - return name - return None - - def _first_action_target( - self, actors: Sequence[str], actions: Dict[str, AgentAction] - ) -> Optional[str]: - for n in actors: - act = actions.get(n) - if act and act.action_type == "action": - t = self._extract_target_from_text(act.argument) - if t: - return t - return None - - # ----------------------------- - # Core loop - # ----------------------------- - async def astep( - self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] - ) -> tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - assert self.rules is not None and self.actions is not None - self._apply_action_mask() - self.turn_number += 1 - - # Normalize actions - prepared: Dict[str, AgentAction] = {} - for agent, raw in actions.items(): - if isinstance(raw, AgentAction): - prepared[agent] = raw - else: - idx = int(raw.get("action_type", 0)) - action_type = self.available_action_types[idx] - prepared[agent] = AgentAction( - action_type=action_type, argument=str(raw.get("argument", "")) - ) - - self.recv_message( - "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") - ) - for agent, action in prepared.items(): - self.recv_message(agent, action) - - acting = set(self.active_agents_for_phase()) - events = Events() - - # Speech first - for actor in acting: - events.extend( - self._record_speech( - actor, - prepared.get(actor, AgentAction(action_type="none", argument="")), - ) - ) - - # Phase resolvers - resolvers = self._phase_conf(self.current_phase).get("resolvers", []) - for spec in resolvers: - op = spec.get("op") - if not op: - continue - handler = getattr(self, f"_op_{op}", None) - if handler is None: - continue - events.extend(handler(spec, acting, prepared)) - - winner = self._check_end_conditions() - if winner: - self._winner_payload = winner - - # Phase advance - if not winner: - self.current_phase = self.rules.next_phase.get( - self.current_phase, self.current_phase - ) - events.public.append(f"[God] Phase: {self.current_phase.title()}") - - self._last_events = events - self._apply_action_mask() - baseline = self._create_blank_observations() - observations = self._augment_observations(baseline, append_to_existing=False) - rewards = {a: 0.0 for a in self.agents} - terminated = {a: bool(winner) for a in self.agents} - truncations = {a: False for a in self.agents} - info = { - a: {"comments": winner["message"] if winner else "", "complete_rating": 0} - for a in self.agents - } - return observations, rewards, terminated, truncations, info - - def step( - self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] - ) -> tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - return asyncio.run(self.astep(actions)) - - # ----------------------------- - # Generic resolvers - # ----------------------------- - def _op_store_target( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - """Aggregate acting agents' targets and store in a flag. - - spec: - - flag (str): flag name to set - - restrict_team (str, optional): only count actions from this team - - message (str, optional): format with {target} - - announce_to_team (str, optional): team name to receive announcement - """ - events = Events() - flag = spec.get("flag") - if not flag: - return events - restrict_team = spec.get("restrict_team") - - tally: Dict[str, int] = {} - for name in acting: - st = self.agent_states[name] - if restrict_team and st.team != restrict_team: - continue - act = actions.get(name) - if not act or act.action_type != "action": - continue - t = self._extract_target_from_text(act.argument) - if t: - tally[t] = tally.get(t, 0) + 1 - - target: Optional[str] = None - if tally: - target = max(tally.items(), key=lambda kv: kv[1])[0] - self.state_flags[flag] = target - msg_tmpl = spec.get("message") - if msg_tmpl: - line = msg_tmpl.format(target=target) - team_name = spec.get("announce_to_team") - if team_name: - events.team.setdefault(team_name, []).append(line) - else: - events.public.append(line) - return events - - def _op_reveal_attribute( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - """Reveal a target's attribute value to each acting agent privately. - - spec: - - attribute (str): attribute name, e.g., "team" - - message (str, optional): template with {target} and {value} - """ - events = Events() - attr = spec.get("attribute") - if not attr: - return events - msg_tmpl = spec.get("message", "[God] {target}: {value}") - for name in acting: - act = actions.get(name) - if not act or act.action_type != "action": - continue - target = self._extract_target_from_text(act.argument) - if not target: - continue - value = ( - self.agent_states[target].team - if attr == "team" - else self.agent_states[target].attributes.get(attr, "") - ) - events.private.setdefault(name, []).append( - msg_tmpl.format(target=target, value=value) - ) - return events - - def _op_keyword_target_flags( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - """Set one or more flags based on keywords found in actors' action text. - - spec: - - mapping: [{"keyword":"save","flag":"witch_saved","require_attr":"save_available","set_attr_false":true}, ...] - """ - events = Events() - mapping = spec.get("mapping", []) - if not mapping: - return events - for name in acting: - st = self.agent_states[name] - act = actions.get(name) - if not act or act.action_type != "action": - continue - text = act.argument.lower() - target = self._extract_target_from_text(act.argument) - for rule in mapping: - kw = rule.get("keyword", "").lower() - flag = rule.get("flag") - require_attr = rule.get("require_attr") - set_attr_false = bool(rule.get("set_attr_false", False)) - if not kw or not flag: - continue - if require_attr and not st.attributes.get(require_attr, True): - continue - if kw in text and target: - self.state_flags[flag] = target - if set_attr_false and require_attr: - st.attributes[require_attr] = False - return events - - def _op_kill_flags( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - """Kill agents listed by flags, excluding those present in exclude flags. - - spec: - - flags: ["night_target", "witch_poisoned"] - - exclude: ["witch_saved"] - - message_dead: template with {victim} - - message_peace: message if no one dies - """ - events = Events() - flags = spec.get("flags", []) - exclude = spec.get("exclude", []) - msg_dead = spec.get("message_dead", "[God] {victim} died.") - msg_peace = spec.get("message_peace", "[God] No one died.") - - victims: List[str] = [] - for f in flags: - val = self.state_flags.get(f) - if isinstance(val, str) and val: - victims.append(val) - excluded: List[str] = [] - for f in exclude: - val = self.state_flags.get(f) - if isinstance(val, str) and val: - excluded.append(val) - final = [v for v in victims if v not in excluded] - if not final: - events.public.append(msg_peace) - return events - for victim in final: - if victim in self.agent_states and self.agent_states[victim].alive: - self.agent_states[victim].alive = False - events.public.append(msg_dead.format(victim=victim)) - return events - - def _op_vote_majority_execute( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - """Tally votes and execute a unique winner. - - spec: - - tie_policy: "no_execute" (default) | "random" (not implemented) - - message_execute, message_tie, message_none - """ - events = Events() - msg_exec = spec.get( - "message_execute", - "[God] {target} was executed. They belonged to team {team}.", - ) - msg_tie = spec.get("message_tie", "[God] The vote is tied. No execution today.") - msg_none = spec.get("message_none", "[God] No valid votes were cast.") - - tally: Dict[str, int] = {} - for name in acting: - act = actions.get(name) - if not act or act.action_type != "action": - continue - t = self._extract_target_from_text(act.argument) - if t: - tally[t] = tally.get(t, 0) + 1 - - if not tally: - events.public.append(msg_none) - return events - - winner, votes = max(tally.items(), key=lambda kv: kv[1]) - if list(tally.values()).count(votes) > 1: - events.public.append(msg_tie) - return events - - if winner in self.agent_states and self.agent_states[winner].alive: - self.agent_states[winner].alive = False - team = self.agent_states[winner].team - events.public.append(msg_exec.format(target=winner, team=team)) - return events - - def _op_clear_flags( - self, spec: Dict[str, Any], acting: set[str], actions: Dict[str, AgentAction] - ) -> Events: - events = Events() - for f in spec.get("flags", []): - self.state_flags.pop(f, None) - return events - - # ----------------------------- - # Observations - # ----------------------------- - def _create_blank_observations(self) -> Dict[str, Observation]: - blank: Dict[str, Observation] = {} - acting = set(self.active_agents_for_phase()) - for name in self.agents: - available = self.available_actions(name) if name in acting else ["none"] - blank[name] = Observation( - last_turn="", turn_number=self.turn_number, available_actions=available - ) - return blank - - def _augment_observations( - self, - baseline: Dict[str, Observation], - *, - append_to_existing: bool, - ) -> Dict[str, Observation]: - acting = set(self.active_agents_for_phase()) - events = self._last_events - new_obs: Dict[str, Observation] = {} - for idx, name in enumerate(self.agents): - current = baseline[name] - available = self.available_actions(name) if name in acting else ["none"] - lines: List[str] = [] - if append_to_existing and current.last_turn.strip(): - lines.append(current.last_turn.strip()) - lines.append(f"[God] Phase: {self.current_phase.title()}") - lines.append( - "[God] It is your turn to act." - if name in acting - else "[God] You are observing this phase." - ) - lines.append(f"[God] Available actions: {', '.join(available)}") - - team = self.agent_states[name].team - msgs: List[str] = [] - msgs.extend(events.public) - msgs.extend(events.team.get(team, [])) - msgs.extend(events.private.get(name, [])) - if not msgs: - msgs.append("[God] Await instructions from the host.") - lines.extend(msgs) - - combined = "\n".join(seg for seg in lines if seg) - new_obs[name] = Observation( - last_turn=render_text_for_agent(combined, agent_id=idx), - turn_number=current.turn_number, - available_actions=available, - ) - return new_obs - - # ----------------------------- - # End conditions (generic) - # ----------------------------- - def _check_end_conditions(self) -> Optional[Dict[str, str]]: - assert self.rules is not None - alive_by_team: Dict[str, int] = {} - for s in self.agent_states.values(): - if s.alive: - alive_by_team[s.team] = alive_by_team.get(s.team, 0) + 1 - for cond in self.rules.end_conditions: - ctype = cond.get("type") - if ctype == "team_eliminated": - team = cond.get("team", "") - if alive_by_team.get(team, 0) == 0: - return { - "winner": cond.get("winner", team), - "message": cond.get("message", f"[God] {team} eliminated."), - } - if ctype == "parity": - team = cond.get("team", "") - other = cond.get("other", "") - if alive_by_team.get(team, 0) >= alive_by_team.get(other, 0) > 0: - return { - "winner": cond.get("winner", team), - "message": cond.get( - "message", - f"[God] Parity reached: {team} now matches or exceeds {other}.", - ), - } - return None diff --git a/sotopia/server.py b/sotopia/server.py index d3710f280..309e8a56a 100644 --- a/sotopia/server.py +++ b/sotopia/server.py @@ -159,20 +159,43 @@ async def generate_messages() -> ( while not done: # gather agent messages agent_messages: dict[str, AgentAction] = dict() - actions = await asyncio.gather( - *[ - agents[agent_name].aact(environment_messages[agent_name]) - for agent_name in env.agents - ] - ) + if script_like: - # manually mask one message + # Only call agents where action_mask is True agent_mask = env.action_mask - for idx in range(len(agent_mask)): - if agent_mask[idx] == 0: - actions[idx] = AgentAction(action_type="none", argument="") + actions_to_gather = [] + acting_indices = [] + + for idx, agent_name in enumerate(env.agents): + if agent_mask[idx]: + actions_to_gather.append( + agents[agent_name].aact(environment_messages[agent_name]) + ) + acting_indices.append(idx) + + # Gather only acting agents' responses + if actions_to_gather: + acting_actions = await asyncio.gather(*actions_to_gather) + else: + acting_actions = [] + + # Build full actions list with "none" for non-acting agents + actions = [] + acting_idx = 0 + for idx in range(len(env.agents)): + if agent_mask[idx]: + actions.append(acting_actions[acting_idx]) + acting_idx += 1 else: - pass + actions.append(AgentAction(action_type="none", argument="")) + else: + # Original behavior: gather all agents + actions = await asyncio.gather( + *[ + agents[agent_name].aact(environment_messages[agent_name]) + for agent_name in env.agents + ] + ) for idx, agent_name in enumerate(env.agents): agent_messages[agent_name] = actions[idx] From ff49e419365098e74d42054747f70beee3d9ede7 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Wed, 26 Nov 2025 10:31:02 -0500 Subject: [PATCH 08/21] update on the SocialGame class / SocialDeductionGame class --- .gitignore | 2 + examples/experimental/spyfall/config.json | 69 ++++++++ examples/experimental/undercover/config.json | 69 ++++++++ examples/experimental/werewolves/config.json | 4 +- examples/experimental/werewolves/main.py | 168 +++++++++++++------ myuses.dot | 0 sotopia/agents/llm_agent.py | 3 + sotopia/envs/__init__.py | 4 +- sotopia/envs/action_processor.py | 136 --------------- sotopia/envs/parallel.py | 111 +++++------- sotopia/generation_utils/generate.py | 38 ++++- sotopia/messages/message_classes.py | 17 +- 12 files changed, 359 insertions(+), 262 deletions(-) create mode 100644 examples/experimental/spyfall/config.json create mode 100644 examples/experimental/undercover/config.json delete mode 100644 myuses.dot delete mode 100644 sotopia/envs/action_processor.py diff --git a/.gitignore b/.gitignore index f6ea22a78..6ca4456a5 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,5 @@ redis-data/* sotopia/cli/install/redis-data/* redis-stack-server-*/ examples/experimental/negotiation_arena/redis-data/* +*.rdb +*.dot diff --git a/examples/experimental/spyfall/config.json b/examples/experimental/spyfall/config.json new file mode 100644 index 000000000..2d8b5c161 --- /dev/null +++ b/examples/experimental/spyfall/config.json @@ -0,0 +1,69 @@ +{ + "scenario": "Spyfall", + "description": "A social deduction game where players are at a specific location, but one player is a Spy who doesn't know where they are. GAME STRUCTURE: One Spy, multiple Non-Spies. WIN CONDITIONS: Non-Spies win by identifying the Spy. The Spy wins by guessing the location or avoiding detection. PHASES: (1) Questioning: Players ask each other questions to prove they know the location without giving it away to the Spy. (2) Voting: Players vote to eliminate the suspected Spy. STRATEGY: Non-Spies must be subtle; Spies must listen and blend in.", + "role_goals": { + "Non-Spy": "Identify the Spy without revealing the location too clearly.", + "Spy": "Figure out the location and blend in as a Non-Spy." + }, + "role_secrets": { + "Non-Spy": "The location is: Space Station.", + "Spy": "You are the Spy. You do NOT know the location." + }, + "agents": [ + { + "name": "Alice", + "role": "Non-Spy", + "team": "Non-Spies" + }, + { + "name": "Bob", + "role": "Spy", + "team": "Spy" + }, + { + "name": "Charlie", + "role": "Non-Spy", + "team": "Non-Spies" + }, + { + "name": "David", + "role": "Non-Spy", + "team": "Non-Spies" + } + ], + "initial_state": "Round_questioning", + "state_transition": { + "Round_questioning": "Round_vote", + "Round_vote": "Round_questioning" + }, + "state_properties": { + "Round_questioning": { + "actions": [ + "speak" + ], + "visibility": "public" + }, + "Round_vote": { + "actions": [ + "action" + ], + "action_order": "simultaneous", + "visibility": "public" + } + }, + "end_conditions": [ + { + "type": "team_eliminated", + "team": "Spy", + "winner": "Non-Spies", + "message": "[Game] The Spy has been caught! Non-Spies win." + }, + { + "type": "parity", + "team": "Spy", + "other": "Non-Spies", + "winner": "Spy", + "message": "[Game] The Spy has evaded capture! Spy wins." + } + ] +} diff --git a/examples/experimental/undercover/config.json b/examples/experimental/undercover/config.json new file mode 100644 index 000000000..3eff5f58e --- /dev/null +++ b/examples/experimental/undercover/config.json @@ -0,0 +1,69 @@ +{ + "scenario": "Undercover", + "description": "A social deduction game where players describe a secret word. Most players (Civilians) have the same word, but one player (Undercover) has a different but related word. GAME STRUCTURE: One Undercover, multiple Civilians. WIN CONDITIONS: Civilians win by eliminating the Undercover. The Undercover wins if they survive until only two players remain. PHASES: (1) Description: Players take turns describing their word using one sentence, without revealing the word itself. (2) Voting: Players vote to eliminate the suspected Undercover. STRATEGY: Civilians must be vague enough to not help the Undercover but specific enough to prove their identity. The Undercover must infer the Civilians' word and blend in.", + "role_goals": { + "Civilian": "Describe your word subtly and identify the player with the different word.", + "Undercover": "Infer the Civilians' word and blend in without being caught." + }, + "role_secrets": { + "Civilian": "Your secret word is: Cat.", + "Undercover": "Your secret word is: Dog." + }, + "agents": [ + { + "name": "Alice", + "role": "Civilian", + "team": "Civilians" + }, + { + "name": "Bob", + "role": "Undercover", + "team": "Undercover" + }, + { + "name": "Charlie", + "role": "Civilian", + "team": "Civilians" + }, + { + "name": "David", + "role": "Civilian", + "team": "Civilians" + } + ], + "initial_state": "Round_description", + "state_transition": { + "Round_description": "Round_vote", + "Round_vote": "Round_description" + }, + "state_properties": { + "Round_description": { + "actions": [ + "speak" + ], + "visibility": "public" + }, + "Round_vote": { + "actions": [ + "action" + ], + "action_order": "simultaneous", + "visibility": "public" + } + }, + "end_conditions": [ + { + "type": "team_eliminated", + "team": "Undercover", + "winner": "Civilians", + "message": "[Game] The Undercover has been eliminated! Civilians win." + }, + { + "type": "parity", + "team": "Undercover", + "other": "Civilians", + "winner": "Undercover", + "message": "[Game] The Undercover has survived! Undercover wins." + } + ] +} diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 0d8ad04dc..b29ca4b51 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -21,7 +21,7 @@ { "name": "Elise", "role": "Witch" , "team": "Villagers"}, { "name": "Finn", "role": "Villager" , "team": "Villagers"} ], - "initial_state": "Day_discussion", + "initial_state": "Night_werewolf", "state_transition": { "Night_werewolf": "Night_seer", "Night_seer": "Night_witch", @@ -32,7 +32,7 @@ "state_properties": { "Night_werewolf": { "acting_roles": ["Werewolf"], - "actions": ["speak", "action"], + "actions": ["action"], "visibility": "team" }, "Night_seer": { diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index 839a2e4bb..32f02932d 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -17,7 +17,7 @@ EnvironmentProfile, RelationshipType, ) -from sotopia.envs import SocialGameEnv +from sotopia.envs import SocialDeductionGame, ActionHandler from sotopia.envs.evaluators import SocialGameEndEvaluator from sotopia.server import arun_one_episode from sotopia.messages import AgentAction, SimpleMessage, Message @@ -89,63 +89,120 @@ def _check_win_conditions( # ============================================================================ -class WerewolfEnv(SocialGameEnv): - """Werewolf game with voting, kills, and special roles.""" +class WerewolfActionHandler(ActionHandler): + """Handles actions for the Werewolf game.""" - def _process_actions(self, actions: Dict[str, AgentAction]) -> None: - """Collect votes, kills, inspections, etc. based on current state.""" + def handle_action( + self, env: SocialDeductionGame, agent_name: str, action: AgentAction + ) -> None: + """Handle a single action from an agent based on current state.""" - if self.current_state == "Day_vote": + if env.current_state == "Day_vote": # Collect votes for elimination - if "votes" not in self.internal_state: - self.internal_state["votes"] = {} - - for agent_name, action in actions.items(): - if action.action_type == "action" and "vote" in action.argument.lower(): - # Parse target from "vote Aurora" or "I vote for Aurora" + if "votes" not in env.internal_state: + env.internal_state["votes"] = {} + + if action.action_type == "action" and "vote" in action.argument.lower(): + # Parse target from "vote Aurora" or "I vote for Aurora" + words = action.argument.split() + # Try to find a name (capitalized word) + target = next( + (w for w in words if w[0].isupper() and w in env.agents), None + ) + if target: + env.internal_state["votes"][agent_name] = target + + elif env.current_state == "Night_werewolf": + # Werewolves choose kill target + role = env.agent_to_role.get(agent_name, "") + if role == "Werewolf" and action.action_type == "action": + if "kill" in action.argument.lower(): words = action.argument.split() - # Try to find a name (capitalized word) target = next( - (w for w in words if w[0].isupper() and w in self.agents), None + (w for w in words if w[0].isupper() and w in env.agents), + None, ) if target: - self.internal_state["votes"][agent_name] = target + env.internal_state["kill_target"] = target - elif self.current_state == "Night_werewolf": - # Werewolves choose kill target - for agent_name, action in actions.items(): - role = self.agent_to_role.get(agent_name, "") - if role == "Werewolf" and action.action_type == "action": - if "kill" in action.argument.lower(): - words = action.argument.split() - target = next( - (w for w in words if w[0].isupper() and w in self.agents), - None, - ) - if target: - self.internal_state["kill_target"] = target - - elif self.current_state == "Night_seer": + elif env.current_state == "Night_seer": # Seer inspects someone - for agent_name, action in actions.items(): - role = self.agent_to_role.get(agent_name, "") - if role == "Seer" and action.action_type == "action": - if "inspect" in action.argument.lower(): - words = action.argument.split() - target = next( - (w for w in words if w[0].isupper() and w in self.agents), - None, + role = env.agent_to_role.get(agent_name, "") + if role == "Seer" and action.action_type == "action": + if "inspect" in action.argument.lower(): + words = action.argument.split() + target = next( + (w for w in words if w[0].isupper() and w in env.agents), + None, + ) + if target: + # Reveal target's role to seer + target_role = env.agent_to_role.get(target, "Unknown") + target_team = env.role_to_team.get(target_role, "Unknown") + env.recv_message( + "Environment", + SimpleMessage( + message=f"[Private to {agent_name}] {target} is on team: {target_team}" + ), ) - if target: - # Reveal target's role to seer - target_role = self.agent_to_role.get(target, "Unknown") - target_team = self.role_to_team.get(target_role, "Unknown") - self.recv_message( - "Environment", - SimpleMessage( - message=f"[Private to {agent_name}] {target} is on team: {target_team}" - ), - ) + + def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> str: + """Get specific action instructions for an agent based on current state.""" + role = env.agent_to_role.get(agent_name, "") + + if env.current_state == "Day_vote": + return "It is voting time. You MUST use the command 'vote NAME' to vote for a player to eliminate. e.g. 'vote Alice'" + + elif env.current_state == "Night_werewolf": + if role == "Werewolf": + return "It is Night. You are a Werewolf. You MUST use the command 'kill NAME' to choose a target. e.g. 'kill Bob'" + else: + return "It is Night. You are sleeping." + + elif env.current_state == "Night_seer": + if role == "Seer": + return "It is Night. You are the Seer. You MUST use the command 'inspect NAME' to check a player's team. e.g. 'inspect Charlie'" + else: + return "It is Night. You are sleeping." + + elif env.current_state == "Night_witch": + if role == "Witch": + return "It is Night. You are the Witch. You can use 'save NAME' or 'poison NAME'. If you don't want to use potions, you can 'action none'." + else: + return "It is Night. You are sleeping." + + return "" + + def enrich_backgrounds(self, env: SocialDeductionGame) -> None: + """Enrich agent backgrounds with game-specific information.""" + # Find all werewolves + werewolves = [ + name for name, role in env.agent_to_role.items() if role == "Werewolf" + ] + + # Update backgrounds for werewolves + for werewolf in werewolves: + partners = [w for w in werewolves if w != werewolf] + if partners: + partner_str = ", ".join(partners) + # Find index of this agent in env.agents + try: + idx = env.agents.index(werewolf) + # Append to background + # Note: env.background.agent_backgrounds is a list of strings + current_bg = env.background.agent_backgrounds[idx] + env.background.agent_backgrounds[idx] = ( + f"{current_bg} Your partner(s) are: {partner_str}." + ) + except ValueError: + continue + + +class WerewolfEnv(SocialDeductionGame): + """Werewolf game with voting, kills, and special roles.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(action_handler=WerewolfActionHandler(), **kwargs) def _check_eliminations(self) -> None: """Apply eliminations based on collected actions.""" @@ -242,6 +299,7 @@ def create_environment(env_profile: EnvironmentProfile, model_name: str) -> Were action_order="round-robin", evaluators=[WerewolfGameEndEvaluator(max_turn_number=40)], terminal_evaluators=[], + hide_unknown=True, ) @@ -253,7 +311,11 @@ def create_agents( """Create LLM agents.""" agents = [] for idx, profile in enumerate(agent_profiles): - agent = LLMAgent(agent_profile=profile, model_name=model_name) + agent = LLMAgent( + agent_profile=profile, + model_name=model_name, + strict_action_constraint=True, + ) agent.goal = env_profile.agent_goals[idx] agents.append(agent) return agents @@ -261,7 +323,7 @@ def create_agents( def prepare_scenario( env_model_name: str, agent_model_name: str -) -> tuple[SocialGameEnv, List[LLMAgent]]: +) -> tuple[SocialDeductionGame, List[LLMAgent]]: """Load config and create profiles.""" config = load_config() @@ -310,8 +372,10 @@ def print_roster(config: Dict[str, Any]) -> None: async def main() -> None: """Run werewolf game.""" # Configuration - env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + # env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + # agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + env_model_name = "gpt-4o-mini" + agent_model_name = "gpt-4o-mini" # Setup env, agents = prepare_scenario(env_model_name, agent_model_name) diff --git a/myuses.dot b/myuses.dot deleted file mode 100644 index e69de29bb..000000000 diff --git a/sotopia/agents/llm_agent.py b/sotopia/agents/llm_agent.py index 497954d7b..53fbfd0dc 100644 --- a/sotopia/agents/llm_agent.py +++ b/sotopia/agents/llm_agent.py @@ -28,6 +28,7 @@ def __init__( agent_profile: AgentProfile | None = None, model_name: str = "gpt-4o-mini", script_like: bool = False, + strict_action_constraint: bool = False, ) -> None: super().__init__( agent_name=agent_name, @@ -36,6 +37,7 @@ def __init__( ) self.model_name = model_name self.script_like = script_like + self.strict_action_constraint = strict_action_constraint @property def goal(self) -> str: @@ -76,6 +78,7 @@ async def aact(self, obs: Observation) -> AgentAction: agent=self.agent_name, goal=self.goal, script_like=self.script_like, + strict_action_constraint=self.strict_action_constraint, ) # Temporary fix for mixtral-moe model for incorrect generation format if "Mixtral-8x7B-Instruct-v0.1" in self.model_name: diff --git a/sotopia/envs/__init__.py b/sotopia/envs/__init__.py index 30b8d8a37..b4255ad7a 100644 --- a/sotopia/envs/__init__.py +++ b/sotopia/envs/__init__.py @@ -1,4 +1,4 @@ from .parallel import ParallelSotopiaEnv -from .social_game import SocialGameEnv +from .social_game import SocialDeductionGame, SocialGame, ActionHandler -__all__ = ["ParallelSotopiaEnv", "SocialGameEnv"] +__all__ = ["ParallelSotopiaEnv", "SocialDeductionGame", "SocialGame", "ActionHandler"] diff --git a/sotopia/envs/action_processor.py b/sotopia/envs/action_processor.py deleted file mode 100644 index 5d459b9a1..000000000 --- a/sotopia/envs/action_processor.py +++ /dev/null @@ -1,136 +0,0 @@ -# from __future__ import annotations - -# import itertools -# import random -# from typing import Any, Dict, Tuple -# from logging import logging - -# from sotopia.messages import AgentAction, Observation, SimpleMessage -# from sotopia.envs.evaluators import unweighted_aggregate_evaluate -# from sotopia.envs.parallel import render_text_for_agent, _actions_to_natural_language - - -# class ActionProcessor: -# """Just builds next observations from actions. Environment would handle the rest.""" - -# def process(self, env: Any, actions: Dict[str, Any]): -# # Record actions (env already has this via recv_message) -# for agent, action in actions.items(): -# env.recv_message(agent, action) -# logging.debug(f"Agent {agent} performed action: {action}") - -# # Build the next observations -# observations = {} -# for agent_name in env.agents: -# observations[agent_name] = Observation( -# last_turn= -# turn_number=env.turn_number, -# available_actions -# ) - -# class PlainActionProcessor: -# """Stateless processor that turns raw actions into observations using base env semantics. - -# This class expects an `env` object with attributes/methods used below (ParallelSotopiaEnv-compatible): -# - agents, available_action_types, action_mask, action_order, evaluators, inbox, -# recv_message, turn_number -# """ - -# def process( -# self, -# env: Any, -# actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], -# ) -> Tuple[ -# Dict[str, Observation], -# Dict[str, float], -# Dict[str, bool], -# Dict[str, bool], -# Dict[str, Dict[Any, Any]], -# ]: -# # 1) Compile actions to AgentAction -# complied_actions: Dict[str, AgentAction] = {} -# for key, raw in actions.items(): -# if isinstance(raw, AgentAction): -# complied_actions[key] = raw -# else: -# raw["action_type"] = env.available_action_types[int(raw["action_type"])] -# complied_actions[key] = AgentAction.parse_obj(raw) - -# # 2) Apply action mask - non-turn agents are forced to none -# for idx, agent in enumerate(env.agents): -# if not env.action_mask[idx]: -# complied_actions[agent] = AgentAction(action_type="none", argument="") - -# # 3) Record messages -# env.recv_message( -# "Environment", SimpleMessage(message=f"Turn #{env.turn_number}") -# ) -# for agent, action in complied_actions.items(): -# env.recv_message(agent, action) - -# # 4) Evaluate turn -# response = unweighted_aggregate_evaluate( -# list( -# itertools.chain( -# *( -# evaluator(turn_number=env.turn_number, messages=env.inbox) -# for evaluator in env.evaluators -# ) -# ) -# ) -# ) - -# # 5) Next-turn action mask policy -# env.action_mask = [False for _ in env.agents] -# if env.action_order == "round-robin": -# env.action_mask[env.turn_number % len(env.action_mask)] = True -# elif env.action_order == "random": -# env.action_mask[random.randint(0, len(env.action_mask) - 1)] = True -# else: -# env.action_mask = [True for _ in env.agents] - -# # 6) Build observations -# obs_text = _actions_to_natural_language(complied_actions) -# observations: Dict[str, Observation] = {} -# for i, agent_name in enumerate(env.agents): -# observations[agent_name] = Observation( -# last_turn=render_text_for_agent(obs_text, agent_id=i), -# turn_number=env.turn_number, -# available_actions=list(env.available_action_types) -# if env.action_mask[i] -# else ["none"], -# ) - -# # 7) Rewards/termination/truncation/info -# rewards = {agent_name: 0.0 for agent_name in env.agents} -# terminated = {agent_name: response.terminated for agent_name in env.agents} -# truncations = {agent_name: False for agent_name in env.agents} -# info = { -# agent_name: {"comments": response.comments or "", "complete_rating": 0} -# for agent_name in env.agents -# } -# return observations, rewards, terminated, truncations, info - - -# class SocialGameActionProcessor(PlainActionProcessor): -# """Extension point for social game state machines. - -# Override/extend hooks to implement per-state masking, visibility, and transitions. -# """ - -# def process( -# self, -# env: Any, -# actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]], -# ) -> Tuple[ -# Dict[str, Observation], -# Dict[str, float], -# Dict[str, bool], -# Dict[str, bool], -# Dict[str, Dict[Any, Any]], -# ]: -# # Optionally apply pre-processing (e.g., state-based masking) here -# result = super().process(env, actions) -# # Optionally apply post-processing (e.g., state transitions, visibility logs) here -# # self._apply_state_transition(env, actions) # implement as needed -# return result diff --git a/sotopia/envs/parallel.py b/sotopia/envs/parallel.py index a03f6945c..fadb70071 100644 --- a/sotopia/envs/parallel.py +++ b/sotopia/envs/parallel.py @@ -136,6 +136,7 @@ def __init__( uuid_str: str | None = None, env_profile: EnvironmentProfile | None = None, background_class: Optional[Type[TBackground]] = None, + hide_unknown: bool = False, ) -> None: """A sotopia environment for parallel agents. @@ -149,6 +150,7 @@ def __init__( self.background_class = ScriptBackground else: self.background_class = background_class + self.hide_unknown = hide_unknown self.background = self.background_class( scenario="", agent_names=[], @@ -288,6 +290,7 @@ def reset( agent_goals=[ render_text_for_agent(goal, i) for goal in hidden_goals ], + hide_unknown=self.hide_unknown, ) agent_backgrounds.append(agent_background) @@ -325,20 +328,11 @@ def reset( return observations - @validate_call - def step( + def _process_incoming_actions( self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] - ) -> tuple[ - dict[str, Observation], - dict[str, float], - dict[str, bool], - dict[str, bool], - dict[str, dict[Any, Any]], - ]: - # Time step ++ - self.turn_number += 1 - - # For action sampled from action space, it needs to be converted into AgentAction + ) -> dict[str, AgentAction]: + """Normalize actions, apply mask, and record to history.""" + # Normalize actions to AgentAction objects complied_actions: dict[str, AgentAction] = {} for key in actions.keys(): action = actions[key] @@ -361,6 +355,42 @@ def step( for agent, action in complied_actions.items(): self.recv_message(agent, action) + return complied_actions + + async def _run_evaluators(self, evaluators: list[Evaluator]) -> Any: + """Run evaluators and aggregate results.""" + return unweighted_aggregate_evaluate( + list( + itertools.chain( + *await asyncio.gather( + *[ + evaluator.__acall__( + turn_number=self.turn_number, + messages=self.inbox, + ) + for evaluator in evaluators + ] + ) + ) + ) + ) + + @validate_call + def step( + self, actions: dict[str, AgentAction] | dict[str, dict[str, int | str]] + ) -> tuple[ + dict[str, Observation], + dict[str, float], + dict[str, bool], + dict[str, bool], + dict[str, dict[Any, Any]], + ]: + # Time step ++ + self.turn_number += 1 + + complied_actions = self._process_incoming_actions(actions) + + # Sync evaluation (not refactored to helper as it's sync vs async) response = unweighted_aggregate_evaluate( list( itertools.chain( @@ -422,61 +452,12 @@ async def astep( # Time step ++ self.turn_number += 1 - # For action sampled from action space, it needs to be converted into AgentAction - complied_actions: dict[str, AgentAction] = {} - for key in actions.keys(): - action = actions[key] - if isinstance(action, AgentAction): - complied_actions[key] = action - else: - action["action_type"] = self.available_action_types[ - int(action["action_type"]) - ] - complied_actions[key] = AgentAction.parse_obj(action) + complied_actions = self._process_incoming_actions(actions) - # Masking actions from agent that are in turn - for idx, agent in enumerate(self.agents): - if not self.action_mask[idx]: - complied_actions[agent] = AgentAction(action_type="none", argument="") - - self.recv_message( - "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") - ) - for agent, action in complied_actions.items(): - self.recv_message(agent, action) - - response = unweighted_aggregate_evaluate( - list( - itertools.chain( - *await asyncio.gather( - *[ - evaluator.__acall__( - turn_number=self.turn_number, - messages=self.inbox, - ) - for evaluator in self.evaluators - ] - ) - ) - ) - ) + response = await self._run_evaluators(self.evaluators) if response.terminated: - terminal_response = unweighted_aggregate_evaluate( - list( - itertools.chain( - *await asyncio.gather( - *[ - evaluator.__acall__( - turn_number=self.turn_number, - messages=self.inbox, - ) - for evaluator in self.terminal_evaluators - ] - ) - ) - ) - ) + terminal_response = await self._run_evaluators(self.terminal_evaluators) # incorporate terminal response into response response.p1_rate = response.p1_rate or terminal_response.p1_rate response.p2_rate = response.p2_rate or terminal_response.p2_rate diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index d3d97afb0..b7dd8e08b 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -8,7 +8,7 @@ from litellm.litellm_core_utils.get_supported_openai_params import ( get_supported_openai_params, ) -from typing import Any, cast +from typing import Any, cast, Literal import gin @@ -386,6 +386,7 @@ async def agenerate_action( script_like: bool = False, bad_output_process_model: str | None = None, use_fixed_model_version: bool = True, + strict_action_constraint: bool = False, ) -> AgentAction: """ Using langchain to generate an example episode @@ -424,6 +425,39 @@ async def agenerate_action( Your action should follow the given format: {format_instructions} """ + + # Create dynamic AgentAction model with restricted ActionType + if strict_action_constraint and action_types: + # Create a dynamic Literal for the allowed action types + # Use __getitem__ to dynamically create Literal from list of strings + DynamicActionType = Literal.__getitem__(tuple(action_types)) # type: ignore + + # Create a dynamic Pydantic model + from pydantic import create_model, Field + + DynamicAgentAction = create_model( + "AgentAction", + action_type=( + DynamicActionType, + Field( + ..., + description="whether to speak at this turn or choose to not do anything", + ), + ), + argument=( + str, + Field( + ..., + description="the utterance if choose to speak, the expression or gesture if choose non-verbal communication, or the physical action if choose action", + ), + ), + __base__=AgentAction, + ) + + output_parser_obj = PydanticOutputParser(pydantic_object=DynamicAgentAction) + else: + output_parser_obj = PydanticOutputParser(pydantic_object=AgentAction) + return await agenerate( model_name=model_name, template=template, @@ -433,7 +467,7 @@ async def agenerate_action( history=history, action_list=" ".join(action_types), ), - output_parser=PydanticOutputParser(pydantic_object=AgentAction), + output_parser=output_parser_obj, temperature=temperature, structured_output=True, bad_output_process_model=bad_output_process_model, diff --git a/sotopia/messages/message_classes.py b/sotopia/messages/message_classes.py index bb8fbc435..c53888b7b 100644 --- a/sotopia/messages/message_classes.py +++ b/sotopia/messages/message_classes.py @@ -46,6 +46,9 @@ class ScriptBackground(Message): agent_names: list[str] = Field(description="names of all participants") agent_backgrounds: list[str] = Field(description="backgrounds of all participants") agent_goals: list[str] = Field(description="goals of all participants") + hide_unknown: bool = Field( + default=False, description="whether to hide unknown background/goals" + ) def to_natural_language(self) -> str: # Format participant names naturally with "and" before the last name @@ -62,12 +65,20 @@ def to_natural_language(self) -> str: if any(self.agent_backgrounds): backgrounds_text = "" for name, background in zip(self.agent_names, self.agent_backgrounds): - bg_text = background if background else "Unknown" - backgrounds_text += f"{name}'s background: {bg_text}\n" + if self.hide_unknown: + if background and background != "Unknown": + backgrounds_text += f"{name}'s background: {background}\n" + else: + bg_text = background if background else "Unknown" + backgrounds_text += f"{name}'s background: {bg_text}\n" goals_text = "" for name, goal in zip(self.agent_names, self.agent_goals): - goals_text += f"{name}'s goal: {goal}\n" + if self.hide_unknown: + if goal and goal != "Unknown": + goals_text += f"{name}'s goal: {goal}\n" + else: + goals_text += f"{name}'s goal: {goal}\n" return format_docstring( f"""Here is the context of this interaction: From 71711b6fe1c32ef56ac403215ba9367f0cae4a4f Mon Sep 17 00:00:00 2001 From: Keyu He Date: Wed, 26 Nov 2025 10:33:19 -0500 Subject: [PATCH 09/21] fix mypy errors --- sotopia/generation_utils/generate.py | 6 ++++-- sotopia/samplers/uniform_sampler.py | 5 ++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index b7dd8e08b..bb982ec7f 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -430,7 +430,7 @@ async def agenerate_action( if strict_action_constraint and action_types: # Create a dynamic Literal for the allowed action types # Use __getitem__ to dynamically create Literal from list of strings - DynamicActionType = Literal.__getitem__(tuple(action_types)) # type: ignore + DynamicActionType = Literal.__getitem__(tuple(action_types)) # Create a dynamic Pydantic model from pydantic import create_model, Field @@ -454,7 +454,9 @@ async def agenerate_action( __base__=AgentAction, ) - output_parser_obj = PydanticOutputParser(pydantic_object=DynamicAgentAction) + output_parser_obj: PydanticOutputParser[Any] = PydanticOutputParser( + pydantic_object=DynamicAgentAction + ) else: output_parser_obj = PydanticOutputParser(pydantic_object=AgentAction) diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index f805a431e..17d11abfc 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -5,6 +5,7 @@ from sotopia.agents.base_agent import BaseAgent from sotopia.database import AgentProfile, EnvironmentProfile from sotopia.envs.parallel import ParallelSotopiaEnv +from sotopia.envs import SocialDeductionGame from .base_sampler import BaseSampler, EnvAgentCombo @@ -69,13 +70,11 @@ def sample( game_meta = getattr(env_profile, "game_metadata", None) or {} env: ParallelSotopiaEnv if game_meta.get("mode") == "social_game": - from sotopia.envs import SocialGameEnv - config_path = game_meta.get("config_path") assert ( config_path ), "game_metadata.config_path is required for social_game" - env = SocialGameEnv( + env = SocialDeductionGame( env_profile=env_profile, config_path=config_path, **env_params ) else: From 39bb4e35141b4706dfee4a462cb0389b744a27a9 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sun, 30 Nov 2025 11:14:18 -0500 Subject: [PATCH 10/21] debugging on the prompts --- examples/experimental/werewolves/config.json | 5 +- examples/experimental/werewolves/main.py | 57 +++++++++++--------- sotopia/agents/llm_agent.py | 3 ++ sotopia/envs/__init__.py | 4 +- sotopia/generation_utils/generate.py | 3 ++ 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index b29ca4b51..0f2b883f1 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -8,10 +8,7 @@ "Witch": "Use save/poison potions strategically to aid villagers." }, "role_secrets": { - "Villager": "", - "Werewolf": "I am a werewolf.", - "Seer": "", - "Witch": "" + "Werewolf": "I am a werewolf." }, "agents": [ { "name": "Aurora", "role": "Villager" , "team": "Villagers"}, diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index 32f02932d..5a9534110 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -import json import os from pathlib import Path import logging -from typing import Any, Dict, List, cast +from typing import Any, Dict, List import redis @@ -17,7 +16,12 @@ EnvironmentProfile, RelationshipType, ) -from sotopia.envs import SocialDeductionGame, ActionHandler +from sotopia.envs import SocialDeductionGame +from sotopia.envs.social_game import ( + ActionHandler, + load_config, + SOCIAL_GAME_PROMPT_TEMPLATE, +) from sotopia.envs.evaluators import SocialGameEndEvaluator from sotopia.server import arun_one_episode from sotopia.messages import AgentAction, SimpleMessage, Message @@ -256,13 +260,11 @@ def _check_eliminations(self) -> None: # ============================================================================ -def load_config() -> Dict[str, Any]: - """Load game configuration.""" - return cast(Dict[str, Any], json.loads(CONFIG_PATH.read_text())) - - -def ensure_agent_profile(name: str, role: str, config: Dict[str, Any]) -> AgentProfile: +def ensure_agent_profile(config: Dict[str, Any]) -> AgentProfile: """Create or retrieve agent profile.""" + name = config.get("name", "") + role = config.get("role", "") + first_name, _, last_name = name.partition(" ") if not last_name: last_name = "" @@ -283,18 +285,19 @@ def ensure_agent_profile(name: str, role: str, config: Dict[str, Any]) -> AgentP profile = AgentProfile( first_name=first_name, last_name=last_name, - age=30, secret=role_secret, ) profile.save() return profile -def create_environment(env_profile: EnvironmentProfile, model_name: str) -> WerewolfEnv: +def create_environment( + env_profile: EnvironmentProfile, model_name: str, config: Dict[str, Any] +) -> WerewolfEnv: """Create werewolf game environment.""" return WerewolfEnv( env_profile=env_profile, - config_path=str(CONFIG_PATH), + config=config, model_name=model_name, action_order="round-robin", evaluators=[WerewolfGameEndEvaluator(max_turn_number=40)], @@ -310,11 +313,20 @@ def create_agents( ) -> List[LLMAgent]: """Create LLM agents.""" agents = [] + # Pre-fill the rules in the template + description = env_profile.scenario + filled_template = SOCIAL_GAME_PROMPT_TEMPLATE.replace("{description}", description) + for idx, profile in enumerate(agent_profiles): + filled_template = filled_template.replace( + "{goals}", env_profile.agent_goals[idx] + ) agent = LLMAgent( + agent_name=f"{profile.first_name}{' ' + profile.last_name if profile.last_name else ''}", agent_profile=profile, model_name=model_name, strict_action_constraint=True, + custom_template=filled_template, ) agent.goal = env_profile.agent_goals[idx] agents.append(agent) @@ -325,19 +337,16 @@ def prepare_scenario( env_model_name: str, agent_model_name: str ) -> tuple[SocialDeductionGame, List[LLMAgent]]: """Load config and create profiles.""" - config = load_config() + config = load_config(CONFIG_PATH) # Create agent profiles agent_profiles = [] agent_goals = [] for entry in config.get("agents", []): - name = entry.get("name", "Unknown") - role = entry.get("role", "Villager") - - profile = ensure_agent_profile(name, role, config) + profile = ensure_agent_profile(entry) agent_profiles.append(profile) - role_goal = config.get("role_goals", {}).get(role, "") + role_goal = config.get("role_goals", {}).get(entry.get("role", ""), "") agent_goals.append(role_goal) # Create environment profile @@ -350,7 +359,7 @@ def prepare_scenario( ) env_profile.save() - env = create_environment(env_profile, env_model_name) + env = create_environment(env_profile, env_model_name, config) agents = create_agents(agent_profiles, env_profile, agent_model_name) return env, agents @@ -372,16 +381,16 @@ def print_roster(config: Dict[str, Any]) -> None: async def main() -> None: """Run werewolf game.""" # Configuration - # env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - # agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - env_model_name = "gpt-4o-mini" - agent_model_name = "gpt-4o-mini" + env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + # env_model_name = "gpt-4o-mini" + # agent_model_name = "gpt-4o-mini" # Setup env, agents = prepare_scenario(env_model_name, agent_model_name) # Display roster - config = load_config() + config = load_config(CONFIG_PATH) print("🌕 Duskmire Werewolves") print("=" * 60) print_roster(config) diff --git a/sotopia/agents/llm_agent.py b/sotopia/agents/llm_agent.py index 53fbfd0dc..8c5b6c622 100644 --- a/sotopia/agents/llm_agent.py +++ b/sotopia/agents/llm_agent.py @@ -29,6 +29,7 @@ def __init__( model_name: str = "gpt-4o-mini", script_like: bool = False, strict_action_constraint: bool = False, + custom_template: str | None = None, ) -> None: super().__init__( agent_name=agent_name, @@ -38,6 +39,7 @@ def __init__( self.model_name = model_name self.script_like = script_like self.strict_action_constraint = strict_action_constraint + self.custom_template = custom_template @property def goal(self) -> str: @@ -79,6 +81,7 @@ async def aact(self, obs: Observation) -> AgentAction: goal=self.goal, script_like=self.script_like, strict_action_constraint=self.strict_action_constraint, + custom_template=self.custom_template, ) # Temporary fix for mixtral-moe model for incorrect generation format if "Mixtral-8x7B-Instruct-v0.1" in self.model_name: diff --git a/sotopia/envs/__init__.py b/sotopia/envs/__init__.py index b4255ad7a..4cf6c0840 100644 --- a/sotopia/envs/__init__.py +++ b/sotopia/envs/__init__.py @@ -1,4 +1,4 @@ from .parallel import ParallelSotopiaEnv -from .social_game import SocialDeductionGame, SocialGame, ActionHandler +from .social_game import SocialDeductionGame, SocialGame -__all__ = ["ParallelSotopiaEnv", "SocialDeductionGame", "SocialGame", "ActionHandler"] +__all__ = ["ParallelSotopiaEnv", "SocialDeductionGame", "SocialGame"] diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index bb982ec7f..74d3dae04 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -387,6 +387,7 @@ async def agenerate_action( bad_output_process_model: str | None = None, use_fixed_model_version: bool = True, strict_action_constraint: bool = False, + custom_template: str | None = None, ) -> AgentAction: """ Using langchain to generate an example episode @@ -408,6 +409,8 @@ async def agenerate_action( Your action should follow the given format: {format_instructions} """ + elif custom_template: + template = custom_template else: # Normal case, model as agent template = """ From eff40bfbe3d33a0db8e25d6d6bb7fe2da9f0a9df Mon Sep 17 00:00:00 2001 From: Keyu He Date: Wed, 3 Dec 2025 02:19:08 -0500 Subject: [PATCH 11/21] refactor SocialDeductionGame for real-time history and fix some visibility issues in the game Refactor SocialDeductionGame for real-time history and cleaner prompts - ParallelSotopiaEnv: Added `include_turn_marker` flag to control environment turn messages. - SocialDeductionGame: - Disabled environment turn markers to avoid duplication. - Implemented real-time history appending via `recv_message` override and `agent_message_buffer`. - Populated `action_instruction` in `Observation` for dynamic prompt instructions. - Observation: Added `action_instruction` field. - generate.py: Added `fill_template` helper for partial string formatting. - LLMAgent: Updated `aact` to use `fill_template` to inject `action_instructions` into `custom_template`. - Werewolves: Updated config description to populate `{agent_names}` dynamically. --- examples/experimental/werewolves/config.json | 2 +- examples/experimental/werewolves/main.py | 86 ++++++++++++-------- sotopia/agents/llm_agent.py | 9 +- sotopia/envs/parallel.py | 49 +++++++---- sotopia/generation_utils/generate.py | 19 ++++- sotopia/messages/message_classes.py | 3 + 6 files changed, 111 insertions(+), 57 deletions(-) diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 0f2b883f1..9964b8070 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -1,6 +1,6 @@ { "scenario": "Werewolves Game", - "description": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players openly discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. STRATEGY: Villagers must use logic, observation, and deduction to identify Werewolves through their behavior and voting patterns. Werewolves must deceive others, blend in as Villagers, and create confusion. Special roles (Seer, Witch) should use their powers strategically without revealing themselves too early. Trust is earned through consistent behavior and alignment of words with actions.\n During your turn you must respond. If 'action' is available, use commands like 'kill NAME', 'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Day discussion is public. Voting requires an 'action' beginning with 'vote'." , + "description": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players go *one* round of public discussion to discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. If 'action' is available, use commands like 'kill NAME', 'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Day discussion is public. Voting requires an 'action' beginning with 'vote'.\n\n The players in this game are: {agent_names}, and during the day, they speak in the same order as they are listed.", "role_goals": { "Villager": "Act openly and collaboratively to identify werewolves.", "Werewolf": "Deceive others, avoid detection, and eliminate villagers.", diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index 5a9534110..a038baf51 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -42,6 +42,10 @@ _gen_logger.setLevel(logging.DEBUG) _gen_logger.addHandler(_fh) +_env_logger = logging.getLogger("sotopia.envs.social_game") +_env_logger.setLevel(logging.DEBUG) +_env_logger.addHandler(_fh) + # ============================================================================ # Werewolf game-end evaluator @@ -177,30 +181,6 @@ def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> s return "" - def enrich_backgrounds(self, env: SocialDeductionGame) -> None: - """Enrich agent backgrounds with game-specific information.""" - # Find all werewolves - werewolves = [ - name for name, role in env.agent_to_role.items() if role == "Werewolf" - ] - - # Update backgrounds for werewolves - for werewolf in werewolves: - partners = [w for w in werewolves if w != werewolf] - if partners: - partner_str = ", ".join(partners) - # Find index of this agent in env.agents - try: - idx = env.agents.index(werewolf) - # Append to background - # Note: env.background.agent_backgrounds is a list of strings - current_bg = env.background.agent_backgrounds[idx] - env.background.agent_backgrounds[idx] = ( - f"{current_bg} Your partner(s) are: {partner_str}." - ) - except ValueError: - continue - class WerewolfEnv(SocialDeductionGame): """Werewolf game with voting, kills, and special roles.""" @@ -310,16 +290,47 @@ def create_agents( agent_profiles: List[AgentProfile], env_profile: EnvironmentProfile, model_name: str, + config: Dict[str, Any], # Added config here ) -> List[LLMAgent]: """Create LLM agents.""" - agents = [] - # Pre-fill the rules in the template - description = env_profile.scenario - filled_template = SOCIAL_GAME_PROMPT_TEMPLATE.replace("{description}", description) + # Define Werewolf-specific template with secrets + WEREWOLF_PROMPT_TEMPLATE = SOCIAL_GAME_PROMPT_TEMPLATE.replace( + "{goal}", "{goal}\n{secrets}" + ) + # Identify werewolves for partner info + werewolf_goal_str = config.get("role_goals", {}).get("Werewolf", "") + werewolves = [ + p.first_name + (" " + p.last_name if p.last_name else "") + for p in agent_profiles + if env_profile.agent_goals[agent_profiles.index(p)] == werewolf_goal_str + ] + + agents = [] for idx, profile in enumerate(agent_profiles): - filled_template = filled_template.replace( - "{goals}", env_profile.agent_goals[idx] + # Calculate secrets + agent_name = f"{profile.first_name}{' ' + profile.last_name if profile.last_name else ''}" + role_goal = env_profile.agent_goals[idx] + secrets = "" + + # Check if agent is a werewolf + is_werewolf = env_profile.agent_goals[idx] == "Werewolf" + + if is_werewolf: + partners = [w for w in werewolves if w != agent_name] + if partners: + secrets = f"Your secret: You are a werewolf. Your partner(s) are: {', '.join(partners)}." + else: + secrets = "Your secret: You are a werewolf. You have no partners." + + # Fill template + filled_template = ( + WEREWOLF_PROMPT_TEMPLATE.replace("{description}", env_profile.scenario) + .replace("{secrets}", secrets) + .replace( + "{goal}", + role_goal, # Also replace the goal here + ) ) agent = LLMAgent( agent_name=f"{profile.first_name}{' ' + profile.last_name if profile.last_name else ''}", @@ -350,7 +361,10 @@ def prepare_scenario( agent_goals.append(role_goal) # Create environment profile - scenario = config.get("description", "Werewolves game") + agent_names = [entry.get("name", "") for entry in config.get("agents", [])] + scenario = config.get("description", "Werewolves game").format( + agent_names=", ".join(agent_names) + ) env_profile = EnvironmentProfile( scenario=scenario, relationship=RelationshipType.acquaintance, @@ -360,7 +374,7 @@ def prepare_scenario( env_profile.save() env = create_environment(env_profile, env_model_name, config) - agents = create_agents(agent_profiles, env_profile, agent_model_name) + agents = create_agents(agent_profiles, env_profile, agent_model_name, config) return env, agents @@ -381,10 +395,10 @@ def print_roster(config: Dict[str, Any]) -> None: async def main() -> None: """Run werewolf game.""" # Configuration - env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - # env_model_name = "gpt-4o-mini" - # agent_model_name = "gpt-4o-mini" + # env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + # agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" + env_model_name = "gpt-4o-mini" + agent_model_name = "gpt-4o-mini" # Setup env, agents = prepare_scenario(env_model_name, agent_model_name) diff --git a/sotopia/agents/llm_agent.py b/sotopia/agents/llm_agent.py index 8c5b6c622..373e8b1c2 100644 --- a/sotopia/agents/llm_agent.py +++ b/sotopia/agents/llm_agent.py @@ -8,6 +8,7 @@ agenerate_action, agenerate_goal, agenerate_script, + fill_template, ) from sotopia.messages import AgentAction, Observation from sotopia.messages.message_classes import ScriptBackground @@ -72,6 +73,12 @@ async def aact(self, obs: Observation) -> AgentAction: if len(obs.available_actions) == 1 and "none" in obs.available_actions: return AgentAction(action_type="none", argument="") else: + custom_template = self.custom_template + if custom_template: + custom_template = fill_template( + custom_template, action_instructions=obs.action_instruction + ) + action = await agenerate_action( self.model_name, history="\n".join(f"{y.to_natural_language()}" for x, y in self.inbox), @@ -81,7 +88,7 @@ async def aact(self, obs: Observation) -> AgentAction: goal=self.goal, script_like=self.script_like, strict_action_constraint=self.strict_action_constraint, - custom_template=self.custom_template, + custom_template=custom_template, ) # Temporary fix for mixtral-moe model for incorrect generation format if "Mixtral-8x7B-Instruct-v0.1" in self.model_name: diff --git a/sotopia/envs/parallel.py b/sotopia/envs/parallel.py index fadb70071..277bc737a 100644 --- a/sotopia/envs/parallel.py +++ b/sotopia/envs/parallel.py @@ -137,6 +137,7 @@ def __init__( env_profile: EnvironmentProfile | None = None, background_class: Optional[Type[TBackground]] = None, hide_unknown: bool = False, + include_turn_marker: bool = True, ) -> None: """A sotopia environment for parallel agents. @@ -151,6 +152,7 @@ def __init__( else: self.background_class = background_class self.hide_unknown = hide_unknown + self.include_turn_marker = include_turn_marker self.background = self.background_class( scenario="", agent_names=[], @@ -188,6 +190,7 @@ def reset( agents: Agents | None = None, omniscient: bool = False, lite: bool = False, + include_background_observations: bool = True, ) -> dict[str, Observation]: """Starting a new episode. Must be called before step(). @@ -197,6 +200,7 @@ def reset( "partial_background_file" (str): Path to a json file which need to contain a ScriptBackground object. The backgound can be incompleted ("unknown" for missing parts), and the missing parts will be filled in by the environment. "full_background_file" (str): Path to a json file which need to contain a ScriptBackground object. The backgound must be completed (no "unknown" for missing parts). omniscient (bool, optional): Whether the agents know the other agent's goal. Defaults to False. + include_background_observations (bool, optional): Whether to include the background (Environment's message) in the observation. Defaults to True. """ super().__init__() MessengerMixin.reset_inbox(self) @@ -248,7 +252,7 @@ def reset( # Lite mode - clear backgrounds raw_background.agent_backgrounds = [""] * num_agents - # Create final rendered background (works for 2+ agents) + # Create final rendered background self.background = self.background_class( scenario=render_text_for_environment(raw_background.scenario), agent_names=raw_background.agent_names, @@ -312,19 +316,28 @@ def reset( else: self.action_mask = [True for _ in self.agents] - self.recv_message("Environment", self.background) - # Create observations for each agent observations = {} - for i, agent_name in enumerate(self.agents): - agent_bg = agent_backgrounds[i] - observations[agent_name] = Observation( - last_turn=agent_bg.to_natural_language(), - turn_number=0, - available_actions=list(self.available_action_types) - if self.action_mask[i] - else ["none"], - ) + if include_background_observations: + self.recv_message("Environment", self.background) + for i, agent_name in enumerate(self.agents): + agent_bg = agent_backgrounds[i] + observations[agent_name] = Observation( + last_turn=agent_bg.to_natural_language(), + turn_number=0, + available_actions=list(self.available_action_types) + if self.action_mask[i] + else ["none"], + ) + else: + for i, agent_name in enumerate(self.agents): + observations[agent_name] = Observation( + last_turn="", + turn_number=0, + available_actions=list(self.available_action_types) + if self.action_mask[i] + else ["none"], + ) return observations @@ -349,11 +362,15 @@ def _process_incoming_actions( if not self.action_mask[idx]: complied_actions[agent] = AgentAction(action_type="none", argument="") - self.recv_message( - "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") - ) + if self.include_turn_marker: + self.recv_message( + "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") + ) for agent, action in complied_actions.items(): - self.recv_message(agent, action) + # Only record actions from agents that are in turn + idx = self.agents.index(agent) + if self.action_mask[idx]: + self.recv_message(agent, action) return complied_actions diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index 74d3dae04..34d6b785a 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -48,6 +48,14 @@ ) console_handler.setFormatter(formatter) + +def fill_template(template: str, **kwargs: str) -> str: + """Fill template with kwargs, ignoring missing keys.""" + for k, v in kwargs.items(): + template = template.replace(f"{{{k}}}", v) + return template + + # Add handler to logger log.addHandler(console_handler) @@ -393,7 +401,13 @@ async def agenerate_action( Using langchain to generate an example episode """ try: - if script_like: + if custom_template: + if script_like: + raise ValueError( + "script_like and custom_template are mutually exclusive" + ) + template = custom_template + elif script_like: # model as playwright template = """ Now you are a famous playwright, your task is to continue writing one turn for agent {agent} under a given background and history to help {agent} reach social goal. Please continue the script based on the previous turns. You can only generate one turn at a time. @@ -409,8 +423,6 @@ async def agenerate_action( Your action should follow the given format: {format_instructions} """ - elif custom_template: - template = custom_template else: # Normal case, model as agent template = """ @@ -471,6 +483,7 @@ async def agenerate_action( turn_number=str(turn_number), history=history, action_list=" ".join(action_types), + goal=goal, ), output_parser=output_parser_obj, temperature=temperature, diff --git a/sotopia/messages/message_classes.py b/sotopia/messages/message_classes.py index c53888b7b..1c8c4f598 100644 --- a/sotopia/messages/message_classes.py +++ b/sotopia/messages/message_classes.py @@ -33,6 +33,9 @@ class Observation(Message): last_turn: str = Field(description="the last turn of the conversation") turn_number: int = Field(description="the turn number of the conversation") available_actions: list[ActionType] = Field(description="the available actions") + action_instruction: str = Field( + default="", description="instruction for the action" + ) def to_natural_language(self) -> str: if self.turn_number == 0: From ca835c339ee207bdca0ab336801931f53ad17e5a Mon Sep 17 00:00:00 2001 From: Keyu He Date: Thu, 4 Dec 2025 02:25:42 -0500 Subject: [PATCH 12/21] werewolf game debug next step, change script_like to false, and fix the rest errors that may cause --- examples/experimental/werewolves/config.json | 4 +- examples/experimental/werewolves/main.py | 133 ++++++++++++++++--- 2 files changed, 118 insertions(+), 19 deletions(-) diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 9964b8070..616df6be5 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -57,7 +57,7 @@ } }, "end_conditions": [ - { "type": "team_eliminated", "team": "Werewolves", "winner": "Villagers", "message": "[Game] Villagers win; no werewolves remain." }, - { "type": "parity", "team": "Werewolves", "other": "Villagers", "winner": "Werewolves", "message": "[Game] Werewolves win; they now match the village." } + { "type": "team_eliminated", "team": "Werewolves", "winner": "Villagers", "message": "Villagers win; no werewolves remain." }, + { "type": "parity", "team": "Werewolves", "other": "Villagers", "winner": "Werewolves", "message": "Werewolves win; they now match the village." } ] } diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index a038baf51..d51b38b1b 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -7,7 +7,10 @@ from pathlib import Path import logging from typing import Any, Dict, List +import random +from collections import Counter +from rich.logging import RichHandler import redis from sotopia.agents import LLMAgent @@ -24,7 +27,7 @@ ) from sotopia.envs.evaluators import SocialGameEndEvaluator from sotopia.server import arun_one_episode -from sotopia.messages import AgentAction, SimpleMessage, Message +from sotopia.messages import AgentAction, SimpleMessage, Message, Observation BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "config.json" @@ -43,8 +46,9 @@ _gen_logger.addHandler(_fh) _env_logger = logging.getLogger("sotopia.envs.social_game") -_env_logger.setLevel(logging.DEBUG) +_env_logger.setLevel(logging.INFO) _env_logger.addHandler(_fh) +_env_logger.addHandler(RichHandler()) # ============================================================================ @@ -131,7 +135,25 @@ def handle_action( None, ) if target: - env.internal_state["kill_target"] = target + if "kill_target_proposals" not in env.internal_state: + env.internal_state["kill_target_proposals"] = {} + env.internal_state["kill_target_proposals"][agent_name] = target + # Update the werewolf kill result + kill_votes = env.internal_state.get("kill_target_proposals", {}) + if kill_votes: + # Count votes + vote_counts = Counter(kill_votes.values()) + if vote_counts: + # Find max votes + max_votes = max(vote_counts.values()) + # Get all targets with max votes + candidates = [ + t for t, c in vote_counts.items() if c == max_votes + ] + # Break tie randomly + env.internal_state["kill_target"] = random.choice( + candidates + ) elif env.current_state == "Night_seer": # Seer inspects someone @@ -152,8 +174,32 @@ def handle_action( SimpleMessage( message=f"[Private to {agent_name}] {target} is on team: {target_team}" ), + receivers=[agent_name], ) + elif env.current_state == "Night_witch": + # Witch uses potions + role = env.agent_to_role.get(agent_name, "") + if role == "Witch" and action.action_type == "action": + if "save" in action.argument.lower(): + env.internal_state["witch_have_save"] = False + words = action.argument.split() + target = next( + (w for w in words if w[0].isupper() and w in env.agents), + None, + ) + if target: + env.internal_state["saved_target"] = target + elif "poison" in action.argument.lower(): + env.internal_state["witch_have_posion"] = False + words = action.argument.split() + target = next( + (w for w in words if w[0].isupper() and w in env.agents), + None, + ) + if target: + env.internal_state["poison_target"] = target + def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> str: """Get specific action instructions for an agent based on current state.""" role = env.agent_to_role.get(agent_name, "") @@ -175,7 +221,22 @@ def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> s elif env.current_state == "Night_witch": if role == "Witch": - return "It is Night. You are the Witch. You can use 'save NAME' or 'poison NAME'. If you don't want to use potions, you can 'action none'." + if env.internal_state.get( + "witch_have_posion", True + ) and env.internal_state.get("witch_have_save", True): + use_potion_guide = "You can use 'save NAME' or 'poison NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." + elif env.internal_state.get("witch_have_posion", True): + use_potion_guide = "You can use 'poison NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." + elif env.internal_state.get("witch_have_save", True): + use_potion_guide = "You can use 'save NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." + else: + use_potion_guide = ( + "You can't use any potions as you don't have any left." + ) + killed_message = "" + if kill_target := env.internal_state.get("kill_target", None): + killed_message = f"{kill_target} is killed by werewolves." + return f"It is Night. You are the Witch. {use_potion_guide} {killed_message}" else: return "It is Night. You are sleeping." @@ -188,8 +249,20 @@ class WerewolfEnv(SocialDeductionGame): def __init__(self, **kwargs: Any) -> None: super().__init__(action_handler=WerewolfActionHandler(), **kwargs) + def reset(self, **kwargs: Any) -> Dict[str, Observation]: + obs = super().reset(**kwargs) + # Witch has potions + self.internal_state["witch_have_posion"] = True + self.internal_state["witch_have_save"] = True + # Werewolves have kill targets + self.internal_state["kill_target_proposals"] = {} + return obs + def _check_eliminations(self) -> None: """Apply eliminations based on collected actions.""" + # Only apply eliminations if we are about to transition state + if not self._should_transition_state(): + return if self.current_state == "Day_vote": # Tally votes and eliminate most voted player @@ -211,28 +284,56 @@ def _check_eliminations(self) -> None: ) # Clear votes self.internal_state["votes"] = {} + # log elimination + _gen_logger.info( + f"{eliminated} was voted out! They were a {self.agent_to_role[eliminated]}." + ) + _gen_logger.info(f"Remaining players: {self.agent_alive}") + + elif self.current_state == "Night_witch": + # Resolve Night actions (Werewolf kill + Witch save/poison) + + kill_target = self.internal_state.get("kill_target") + saved_target = self.internal_state.get("saved_target") + poison_target = self.internal_state.get("poison_target") - elif self.current_state == "Night_werewolf": - # Apply werewolf kill - target = self.internal_state.get("kill_target") - if target and self.agent_alive.get(target, False): - # Check if witch saves them (would be in Night_witch state) - saved = self.internal_state.get("saved_target") - if target != saved: - self.agent_alive[target] = False + # Check kill + if kill_target and self.agent_alive.get(kill_target, False): + if kill_target != saved_target: + self.agent_alive[kill_target] = False self.recv_message( "Environment", SimpleMessage( - message=f"[Game] {target} was killed by werewolves!" + message=f"[Game] {kill_target} was killed by werewolves!" ), ) + _gen_logger.info(f"{kill_target} was killed by werewolves!") + _gen_logger.info(f"Remaining players: {self.agent_alive}") else: self.recv_message( "Environment", SimpleMessage(message="[Game] An attack was prevented!"), ) - # Clear kill target + _gen_logger.info(f"An attack to {kill_target} was prevented!") + _gen_logger.info(f"Remaining players: {self.agent_alive}") + + # 2. Witch Poison + if poison_target and self.agent_alive.get(poison_target, False): + self.agent_alive[poison_target] = False + self.recv_message( + "Environment", + SimpleMessage( + message=f"[Game] {poison_target} died by witch's poison!" + ), + ) + _gen_logger.info(f"{poison_target} died by witch's poison!") + _gen_logger.info(f"Remaining players: {self.agent_alive}") + + # Clear night states + self.internal_state.pop("kill_target_proposals", None) self.internal_state.pop("kill_target", None) + self.internal_state.pop("saved_target", None) + self.internal_state.pop("poison_target", None) # ============================================================================ @@ -395,8 +496,6 @@ def print_roster(config: Dict[str, Any]) -> None: async def main() -> None: """Run werewolf game.""" # Configuration - # env_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" - # agent_model_name = "custom/google/gemma-3n-e4b@http://127.0.0.1:1234/v1" env_model_name = "gpt-4o-mini" agent_model_name = "gpt-4o-mini" @@ -415,7 +514,7 @@ async def main() -> None: env=env, agent_list=agents, omniscient=False, - script_like=True, # Required for action_mask to work + script_like=True, json_in_script=False, tag=None, push_to_db=False, From c0f7866eaba80d7570a044912123866768d69a06 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 21:50:15 -0500 Subject: [PATCH 13/21] Refactor social_game.py and update werewolves example --- examples/experimental/werewolves/config.json | 84 ++- examples/experimental/werewolves/main.py | 26 +- sotopia/cli/install/redis-data/dump.rdb | Bin 91774 -> 502359 bytes sotopia/envs/social_game.py | 515 ++++++++++++------- 4 files changed, 418 insertions(+), 207 deletions(-) diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 616df6be5..ad2a8da7e 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -11,12 +11,36 @@ "Werewolf": "I am a werewolf." }, "agents": [ - { "name": "Aurora", "role": "Villager" , "team": "Villagers"}, - { "name": "Bram", "role": "Werewolf" , "team": "Werewolves"}, - { "name": "Celeste", "role": "Seer" , "team": "Villagers"}, - { "name": "Dorian", "role": "Werewolf" , "team": "Werewolves"}, - { "name": "Elise", "role": "Witch" , "team": "Villagers"}, - { "name": "Finn", "role": "Villager" , "team": "Villagers"} + { + "name": "Aurora", + "role": "Villager", + "team": "Villagers" + }, + { + "name": "Bram", + "role": "Werewolf", + "team": "Werewolves" + }, + { + "name": "Celeste", + "role": "Seer", + "team": "Villagers" + }, + { + "name": "Dorian", + "role": "Werewolf", + "team": "Werewolves" + }, + { + "name": "Elise", + "role": "Witch", + "team": "Villagers" + }, + { + "name": "Finn", + "role": "Villager", + "team": "Villagers" + } ], "initial_state": "Night_werewolf", "state_transition": { @@ -28,18 +52,30 @@ }, "state_properties": { "Night_werewolf": { - "acting_roles": ["Werewolf"], - "actions": ["action"], + "acting_roles": [ + "Werewolf" + ], + "actions": [ + "action" + ], "visibility": "team" }, "Night_seer": { - "acting_roles": ["Seer"], - "actions": ["action"], + "acting_roles": [ + "Seer" + ], + "actions": [ + "action" + ], "visibility": "private" }, "Night_witch": { - "acting_roles": ["Witch"], - "actions": ["action"], + "acting_roles": [ + "Witch" + ], + "actions": [ + "action" + ], "visibility": "private", "internal_state": { "save_available": true, @@ -47,17 +83,33 @@ } }, "Day_discussion": { - "actions": ["speak"], + "actions": [ + "speak" + ], + "action_order": "round-robin", "visibility": "public" }, "Day_vote": { - "actions": ["action"], + "actions": [ + "action" + ], "action_order": "simultaneous", "visibility": "public" } }, "end_conditions": [ - { "type": "team_eliminated", "team": "Werewolves", "winner": "Villagers", "message": "Villagers win; no werewolves remain." }, - { "type": "parity", "team": "Werewolves", "other": "Villagers", "winner": "Werewolves", "message": "Werewolves win; they now match the village." } + { + "type": "team_eliminated", + "team": "Werewolves", + "winner": "Villagers", + "message": "Villagers win; no werewolves remain." + }, + { + "type": "parity", + "team": "Werewolves", + "other": "Villagers", + "winner": "Werewolves", + "message": "Werewolves win; they now match the village." + } ] } diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index d51b38b1b..45adffa1c 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -14,6 +14,7 @@ import redis from sotopia.agents import LLMAgent +from sotopia.agents.llm_agent import Agents from sotopia.database.persistent_profile import ( AgentProfile, EnvironmentProfile, @@ -249,8 +250,23 @@ class WerewolfEnv(SocialDeductionGame): def __init__(self, **kwargs: Any) -> None: super().__init__(action_handler=WerewolfActionHandler(), **kwargs) - def reset(self, **kwargs: Any) -> Dict[str, Observation]: - obs = super().reset(**kwargs) + def reset( + self, + seed: int | None = None, + options: dict[str, str] | None = None, + agents: Agents | None = None, + omniscient: bool = False, + lite: bool = False, + include_background_observations: bool = True, + ) -> Dict[str, Observation]: + obs = super().reset( + seed=seed, + options=options, + agents=agents, + omniscient=omniscient, + lite=lite, + include_background_observations=include_background_observations, + ) # Witch has potions self.internal_state["witch_have_posion"] = True self.internal_state["witch_have_save"] = True @@ -278,9 +294,7 @@ def _check_eliminations(self) -> None: self.agent_alive[eliminated] = False self.recv_message( "Environment", - SimpleMessage( - message=f"[Game] {eliminated} was voted out! They were a {self.agent_to_role[eliminated]}." - ), + SimpleMessage(message=f"[Game] {eliminated} was voted out!"), ) # Clear votes self.internal_state["votes"] = {} @@ -514,7 +528,7 @@ async def main() -> None: env=env, agent_list=agents, omniscient=False, - script_like=True, + script_like=False, json_in_script=False, tag=None, push_to_db=False, diff --git a/sotopia/cli/install/redis-data/dump.rdb b/sotopia/cli/install/redis-data/dump.rdb index f2b5cd251d4af68bbe9bd86ebb39e66937b668f2..b5d54d63a9c0cbd11069397cc9494c58cb9b3402 100644 GIT binary patch literal 502359 zcmeEv349yXwf-H+OKispB!sX8az|cdJC37$aRSw7w-gC&(MF0jmd3VXSyHsvF%+U7xcN{uxn^PbViVjpHs8Be$w2w-Z%3CBY@&lFG10CU1azu5YG?)ta zQqP|EfFyiiRys+AmiAJ;&j#LBg9Ee3Sm)9XGD(eqPlg_Tq4&kx9^Hv?F!bcIx5n>! z_2HchIT&Y7>GIZOEES7~$(9hABI)y6`pIxA9PMgP#A3ZI7BUs=M$g)!p6p=oSd>!ZoCI@u+q}n7W%;~4&V$;PW^}eihZYaHnlDuPEhUir=`!FQ`?ev zgp+GhT1lUlOp(2D`rO&IEjiEg&#VeY!c;WX9u6hxQ%|XFIdtH{rf;)G_2(8>bSvDV~URh9gvqF4`AP#G<|Em@RuK|B_Z6{gPG$V<9R^ zX0K-za{R%kEZ4t08A~UEnM+@R{5AE2<;03)5WQ$39HU)?UiHvZmXl|bUG$ggijk4b z~Imtvuerchq6cGEI@`RIEKaizM57!|TG) z76Kj8Wo8-+{XBn6bB;cyzuYu+@N2W&%E$2Gj{2QdxzC879?r~a=q{ap!g9eYvmaq<)bc zbyT-C6uvCcKz&w0-BN#`c0elihC(u+fHe0qLk z9%L~*cui}L?(w5uwe2dcYUSd>7oz&eNIE-*hQhrZDw>29vyv^EG#dH6x>0a8(2kr{ zxe~2!`B(KAhI{Gs>Q!Fbl6}bXCFh`bMQ=*)7vepc%Q?TcCGXhbF2h0hZ2y+ZaJ=8F zH1o}l>b<3fjw7SdSc*h5L9&IIe)!e6sEq0*!x4I{%|iD@_A!em5Bh`U24(%QNB!2V zh2J`Jr(0uDX9^AaUUUmM!@cS3a_>amNGJ1Ma13ig6_uSg(B-GUVm11TIR}pPJ*CI< z@s9eXx2setO76SND`# za}VV2z$&wUip2WqQ|SpUW1D%Nf9An4wtcxubG%N%T90}WM2ESwQ+FD2gFcp+T5$4R zUUGDo&zc%w^p~wXINVrwUeyP0oT+u2UW=#i_RJv`*PohuMtf%}GpiLCVuu2Y$w>}4 z7;lsw^fAk%lezs^op4l_Dhgf761WibCWkd-nR|4{R&|CG$yEFFw2yA(+ygA0s*I31 z=c9p_^PJ`M612?I7Y%KdGmrAeX4RoB|I~=jJ;3sE<>>VL5Sb_HXn8 z9*w0lcOZ&Tm6H*se1~2%R^6DPA={3NQLRwMEi*BLza_{VUKt4#u9(|Tj8j; z7fS%ea(n3x?P?3RRL3xN9n)%2owIk&jD>l5+v()tHdIsL{uK5!9fi~A4~<40&g!67 zS)#OK@W4?WyIt`umW5^-Ekc+hMv z(P??FnLId8qggHYFZ8FH{v*q)92}b4#jRg<3?0KJRu1XL z9g4SZZ0443SA9eX7hl4Q_wZVIhc&2H@|t;vwn?y&t4@%;a6T3x(M$&CU}P6Od$ypN zcW5&Mo8ZFI?CxWfTU9o-Eg4>1nX|VTtSy_`IY5i*{KdTp#fnVr4tT8>e!H2M40a<_ zB6V?lW|sh>qh;reoK`lqj~E3?mANFd?!()w+H>{pRyWMYZyK)(@+tV@Sx!7 z8MiLTy7gR!TbF+Q-Rd}|p_;SH$QiJN&n0j;3`&o}tZ}+UF1^R+v^!ln&eb=zU7h1x zc7rMxa_)380`g&XNhaMeQU!wW3=YaHz%xr--Qgtcf^`7G^2I?i5}`uSLm|j37U`ps zumR;0Vz4I~>yJV*Sq&{@XP9hkhW*{)V7E$mDhZRZV3>@+5EV*i7PY|2c`Xnc2#XFn zU5-^6*D8mu6^>%vixD`VLTQ5}OorFNc!V54o8DHK>W{&`a3q2TPYxqZg3zN33%txshlK?bwVo&ol`L0O(x;;2_tL-p=!}Ch@U;i zITNlJ1$PCOmiKp4QJCtcpaff+#!sn=C15O_il%!zsDx_aD7Jcb3$(Twj4o(15>6dV za~gz=qa`ajWDgbCJvW{R_mL?oG5~|!u~?D{d^EBJgDL!0_#g+Sld2y8exM3i@nk8# z1vZEp@hFFaPJCIwif_Xuc!CqBg2cJFd8mKnyy3G3RVsgZgd!7B817U(!5x5P0^T@s zFX&XgF|r9pfOs$hJj8XQoLgWmzMjz;_$PB-KS zP7OSP!3G%TCS$!6T*`r=F#ZrH6^jxtfJRkfw9Lqj$HGZo?`Vl>c>dtZXW+1{qy=gT z-&jeqOiT7hTOomKhGv80@JF2f?%3d^oKQMJTnN_z7}yl}A(xB}G{TSYjxudH8B8aW zyuS_VIFWFls%2y)Hx{R&_|HLTunx+h4Wl~noq+)8RlSPAjxnx>(*jrF%K#tWijh5m zr?F%=u?`H>JPtQtu{3YzpuRMjPR7GQ*v~T&mjO3!WZk2h~mXg=^kZ{Sj9arV^BDOHqN>?g^@7fpzriK!tgeAO=@xcXK)-6mLQ8nmYe> zd+mEFGY5r%#3OY}kdKD3SQP#Se_R*lBw`W#b7Z?O36NivywQDW{f{_gR$OTd|}PK$T{ck=6nE}cb7c{=kCVr zaQ1HQeEdU@7%zQCUp$yf`&6fOEF4U1bKRf-a`yjBr81lrZxc%RNeSyx_bG#!a#cNsOe z!iLexMMIB{R!G_w%sQCT9BHFh_(tg%CONT1-sN+s#c1(yDBVi8T`Y9zd|F-3I>g*K zDcKCFb&N&+WZpOte!W(26&oE|v)1hN=qw7Y)h%-5`t%b*oV&KO8dN(NJ}uFqkdT6> zWwuT8v)iUc)#Ci^Xfr(HOKeB2-=OWNMWnDBd=87wFH!j961hbr^$_Y@uYOF3a=fZ$ zHKODl>_-YJrD<2Gs<^sioRj zB1}f%g3MSwEjw1vVR*H9#(LFg(fR}?tH|v(xSR^D(`WbFO~NC1mDQkHSoCU6$fP!^ zQG{p;YUY zN+NIlX9W2c5sN{=`affW{NtNf2|wY`d;A86RV>sC%`THf=JR>-W`NHKY0hHBZcs1- z9LBB8O+d?x-2|vbR*g|&Guqu6f!Sb}Xq_5s{w9DiTv-hYHUWq6D`OV;24GUhH(b%G zuJ`EF8jD2i^SeD#i{ELLEApmRhFe(;3Z~YR-K}P))M&LE>|V28XHr{bN}X6{Hy&Zd zVl}8X&de3-MDSCE)1(keMQWEuXS6A`LYLdC)Vl3CuC)yGf0Q%9vKv&{k!z2PpDu1g zP(?b~(~Btm%^6@bi)A8c6LygZK0G}EDKgj%qZx=Y6@!STi{L;A&*Z}Ha0muYf&GWz zRP~u36v;+2t4f`#r_ktz)|?5AHI0W{RS8{2n7dKBqy#&8x z3hBjNRk=Vf`~tYg!0!;AhmhW9I*2L`!nembbK#0H>_*j6tiKx{$KQt%@`=(cx;ylX z46<95i|i7qJYbhg1H1VDm1comwVB|KY*BZB9tN!YVganniysx%EfSbNm6|Wf^-{>1nD9aE_eZr=;9ZSa;Gre$P}hqvw=MAm{Hw(|EOZYbpLvEOt<5Q zHP4S>hkJ^&YSXG(ED39V$&TvQ-WEWpZtY9lLQt3dJv@g~0O~$> z)sgVr+HJ(+AgDS5qDw7544k_o1Lyv98qWRMK{)riTsZffV}x^USvVI2>&x*Q^5NX> z44k`x9nP(%;auL%LvZe<%Ap> zGv4#U>xQS)kHIyVa{ylgdPyn1iG#pZ_#2?z=-BYTynm`H)}LLoghrlgY#HPkWe)hn zhAHHk*9e-)KWC6%9~iQr{row*x%P&-;<>Z3e6^)h$ zI$H+2{=4x(Kz;yz%ztO1>T#g{)tjCB8e2i9@%p!cA7y&Nu%`h6DiSo`*7G}lHwWdW zTgI?NXxW>Mv;P7*Vb?_2lRyJq@Gkxuj7l}m3v^?hl=a=lD>_d{82FVtukmkQHBr$D zll-?qEdG${)y7LeFMs#u2XJ6~oGV?om zf=P7awGEH$pX@_F)~;GN`c?P8)JwXH(Sw~E@cB!AP5%w{VH!##S@J1ni8X+R^y`AV zef!X_1Akx1rJ=Fbm%q)w%lzRjzf)DW_)4?s*5z+;&uF`vIJoe5Yd=NBPNHE6~HoPDgKOdSYn4hKm0udbs2O7x7KH$oc3oAoufrQI!R! z^<*Lu>z|mt=ZZfZ5IzW2!Hd9v-~fPKmv_I;LD@#3os*1{{(Cr)f#nVFVX@TGzv%^B zM7ijdzjgrpARrX_uy9X33A!6g0BPR|I?eyJ)`w9cp>BWK>pu$w(w(<#>MvJqn&Jq0_x7co!Cr`ELeExLUXq45$p;9yITaavuc|vr4rO zC2e|ROUyr)1)kxbe&5jYOgcr-glTvGQfLW=Qw&b#a#P#0^Rs|Ekjs>TG0-Fi`S!R1T7<7;b>HKF8(;^9=sfFNzlY&fLqGwG$q>bnXiuR z)6G*g4c`v>pqWeO`tfTyDA&(xEKg4D^&0Lin@aKXFf5c_aRt1=cmSg}dYIGo&g23> z?FS;>#}$WC;n@w-7`GeiCu?rtg`WAmri_D<;5FBGzYN^G1R$G#q@LGt*~8BSsyZ$b zeh-Ll{GR)qfK^?A=A}1yUi6~)!3=ZFv+57<+tDHVtziA{P-YG#KX-BOM$fr)%Cr6j zt(Sk(0r&@?uisdc1fl3#yTQx+ho+Vm{p%o6yOqD2{}AR%)c>XSG5)L84^Zh$@VDDJ zh-Xu`Q0H#F9z-3K zS6QNDUNf(L0<;tV`0mK;++1EfK&SGc2;HBfl?#nG&p(*W51gB9%>-nO)Z-$WL`$CLzTnl{Q%B7Zz&e;g=^6U zc-a{CPq0j7L9b)xBwN-jxdD0yKL^QCnWqG0eW~_g(}-_yepBU!^&qKwvwV8p=DmVZ z3Ap2z61aKQ-0z8aACTCOcA@Eh(HG?j*lN_kGe0W-DLiwOBj3k;S9Rvf*P&z=_8vTK z6l^HF^wMS?%{ZDjh6ztBAL*1zd?vliB66wSPMh0gK*=$0PF$z@I4^gmRC~6}*Cr%DFN`cY5|!)g!Q?^-Nr}tj)_Oz^6N;3jUW?eLQ|FS6RQXvRc7tk~Y?RB6 zV@1xHSmex2NGh!sy^v6t#A<^}q3}3OVxQ4s$sMYnF!*t72GtgZSJ{Uu)2&OtEX0e( zYVS8-tg6i}m%{3@Y5YQw)+KSeWlon@lIK+h6v=8(;MJ2Jilhm$PPT$Jbwk2%a_g#asT2$cxWS6Fhy0;JF$RJhv10G{Lhi7YgJp&JsMerA&e+ z2N68+Iz*1937)+y1kd}j1WzgP7$SHErwE?ghC=xJG`CZ|@!Pep<#0RAvuJK7q*;q_ z7DVV#VEwENZ4ogro8e!zE3w{Qcs1`d5U#m?7pEOG?<#A7bH}k~;p}nl#@fd~Vyv`P zd)X-V6IK7ne-Hf!{D`J4;%DHq5N&ZPwj-`2W8^g`vRfTp9UbFbjj!X`_kdUM*?8T& z4B*xb=^}+@n5EUj(W)%>rWw)^IbvMv;p!{AQBv3CE+l_g?K;2AECLDoiqoQ@1d(_on*_P6cch|oQQgA>1oPiAd9UO%>$q%WLa~ZtJY^;@` zO&ZT+(k2^}2WgWhH1QKYpG9qS*xVwq(IPcy9U7s}Bh8uk860S&g+S<$Xw*)^X))#ncg*TXHiLrTZf219PiTcV zxV=u9U98YqWqO;>;D?F<~m94nK7gzY=3SV5|iz|F_g+Hzp-XalsM1Gf2 zM`*NipUmYoNoCfYb%B6KGJixOi!c`I=y1O(5f-WymFh^kn2+> zN1v|iV;WQ&7(P8rrh%EjnXw#wRLWp-n_O<8(&iC5*Q z$ssZrwO)VDU?r*vZBFin-Jsgf9IUJbkjy#YODA!^0__-BKer7cksETFrQX>BpE)Qe z1J6ClNp7NmP*(kgO`OPC}iR)cD~xFnY^!@hzsy!xf9qW|qAq!R*EGV&2JyI*W_$ovwA z%_?;pHMu2G%#;bMK^0vRRm_zab&ra=M@8MEqV7>q_b9KY(=nt>vJ6*;+UoH6WlD`z zCU*#>l3dOfqQvHk8?qZz)10lNFY(OX=$GF2e^bbtMzu+#5l9K4RcYA*$t{_rJmx( zskm_}Zk&o6r*F*0iMMsEyqx#(IJmX$W)6}~z+Zz`*M?EaSs31hYmm-uYVhH`*aFZ} zzoY)#DHh{xTw45~liDKWAk~BQIPWyopOD_c-Z?w=p7K<{y?63S{PuC|F7!LNvhK(F z-)xGZhuNQc&fZ6!>YUYz& zqtjK*?_f)tgr;Q?1FC8^UOZjZ%-?aas+oTvr>fahi>jI%{@HLEs%kd;SwGo?-G4{Z z3gZP0ck(;<!PP%kKk+ziFpHZTb*Q-6=1nW|luevz$QPC}zOaecOOS$a3BT$V~3XMY5C9;R}6 z)i!}Teq*L``KE;jE0?iYd=<?u5`mwXgwk&9C zu-%_2TvpB7oKv`bC%D3xEnEI)$<_H~%c2V5HJ?@N#He)*U% za#`|ue&w>{9mGy{?4NuQbk&{e--jMd{ha&aQA?HA{RxDGaxQ9Eke1zbs8m_LUHLZ< zv%}m{WiCXuu>RXowQ|L@VzjHfuwePW4J&LMBpA&sSUxYz`?0E8QkNACS-ow#Vwr

SgiV>FVXN!&WahK7Xis8H41KGwA~6r&ejJzijz3aMFdwPoj$CWfJXn>GTfk6FNsUc6tkyX1QH3N1%{zP4HY^nuAgpo8zU zETAiuo4Xg;DP1h->ZVb%t_|M!L<*UaoK86})o&HPJDDzwX^)<>%f=8}V z24UA{U#3L)mZO#^-+%GJ66K4?e@lt7!7DIoby~t8_PhLUqr{*mw6-Hu)2dkw3aV*m zN-5p(u`y6hoW||ZTht1@#-)*KWm1<%Bgu_#QO<=S@5^FPkl$vlQDz_qUwd^=uzdw< zhrj7gN34yPDzm-#T9J@ecY{I_QU(>2tTWUp6evTTAyOu!B$ALMB$No1lr%_+I;2XG zx*(h${FI+6Ujp=yi$UYdJP7FMrZbVWG)F(Y}e;1Ubg+& z)1M2cu^Lp-V!}l@tq7+T;j|*0HnZWhG8#@pNLt5&T%kYWbU=;)>nv2AoK^Z$w;`p! z1Ni0jy;Dkmex8_C`uhiY@iZnjj=fOxlIB;`7{bKVr%hvGc;Fx=hToiniD{mumHuwS zAEcH3)~$aK{-r}w^GD(xo)mtLe`kXOUdG!I+=4&*!j(u&>kQ4Dtl-~zB=~m?{(sY$ z*v21iY^re~!M{Y!`i$V;wyfaa4M_0sjcLKZLD1YGox;TGIH<}4h{nYJ zn-=^_)L21ccd7B7Pm$o?{wyZO^WFER>J$7E`~zC>?{~XOK0tzh`!TCENLcAKny!+k(C9rF} z%$?Qh!%OIz>>p>e`iyVZ^aYky7r_Y2+ugk#}%@T$kFuGjr(TzoZX`|i87kKvDX zyaZs~!u4m>tl$sPP*%;Q_`6%8ym=j)RP)xG@i)iIE<+l8q1&$fP5pf}Tky*D>8)+= zmiWN(HZrTh$9oTS)m&6NvbW-H1ZMGWhl@1>AW?r;)5g8v&hg3EI4_ogr=S2S>D2{k zCB6E}-Iht<+$PgS(Pe!T@fjazysz#Nq@%~*#V@J94)iQ-+|t)@Z~ZgoIUrdxAH>Hi zQ@bbsW}JWZ5Bcf0x2@-(iuvX@O0ybvknBF_q4+dQ#nR`s@)K$`#EhF9agpxL~K z|C!2Ax3Zxd#KsmrM9bAJsYP;iwxoICb#Iu>btSY)9q2Lr&nV~7mbHV^D9;s-XHlM& zzkpA?J1N@q{ddn20ezFbWvF@nzO&3o^Q_KSyMyPs_FNEr4yjT0_VG&bC&emW#Y?+Y z^JDfG%Rzd{{44M8ay}BkOaJ4R5UpCaj9aK$R@-_wM4)L6oYnXb z@g>qU7FqTI=FHVB6PuuS1M)c1EVB)RDaA73jvE@5{T#c5_hHE+aK~>kxY?+ApYs#A z=;eFhqW8*G@Er3pc;-a;yBU$Pz1)qeGj)#~QKaldwvcMG!>T7xR@UhwJQjn+=FzA{ z#$28|Q}%_`po-?X7q^hbE#%j43t5zXDayXgP}!H`1S!=bi`}PJn#BgI8})BBT6G8k z&g+)LD70raDCm}RGMC}0GcGbnx8Rf6gIIM6u{{-rm*Qm_9zOO zS?#fioi@}r%j$K?eOjqaq|D7#=u>uUF3iPha13pRzlm)!PRVVPk!_7Jw0K$#7ij>~ zt%mjKD83bQ9UsV%vA?tUZmBGpTfR<`w9n=>pdiTk2KWa zRxXU9_P6{BvI8|M&NNxZ*H3j5z()d6r1K1iu2Mx2TjMe;8Hd~OWn-MHh$t6^kf!t+ zu5%!nLJ?;W-d2jT1{pnkt~Q+r;}4+t!^uUx5u&Ig>kw)|pG4e|P*&WGgkO}zqI?*J zHf{qcGUM~(X7o{LxxHR}Ju2T|Z$ zH46Sn%iN6Xe~JR=!1{V(EzgWKL;Q>3oq??4 zJz>ZM(M#wyyj#X6<3l~;XT`a+avQIV1E)f1;abpk@5Sg8{H~2FYCm2NYDz$;ZU943 zw65;yEiU}ijtGjPh4?e$Wg$9>Zr`*3ODEOb8glT%t*7oTDXCZvQ7h?;mObych}ThC zyBW2v-NZrNzqdT9eZTWUxLA7)Cy}ITOHucb`n7

w-6pPp%rG#?M-niL3EjP+ZNw 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 De^&Gr diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index 8e3f34071..c636d4706 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -1,25 +1,173 @@ -"""Social game environment for multi-state games like Werewolves, Mafia, etc.""" - from __future__ import annotations import asyncio import itertools import json +import logging import random +from collections import defaultdict +from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, List, Literal, cast from sotopia.envs.parallel import ParallelSotopiaEnv -from sotopia.envs.evaluators import unweighted_aggregate_evaluate +from sotopia.envs.evaluators import Evaluator, unweighted_aggregate_evaluate from sotopia.agents.llm_agent import Agents from sotopia.database import EnvironmentProfile -from sotopia.messages import AgentAction, Observation, SimpleMessage +from sotopia.messages import AgentAction, Observation, SimpleMessage, Message + +logger = logging.getLogger(__name__) + +SOCIAL_GAME_PROMPT_TEMPLATE = """ +Imagine you are playing the game as {agent}. + +Here is the description of the game: {description} + +Your ({agent}'s) goal: {goal} +{secret} + +Here is the context of the interaction: +{history} + +Your available action type(s): [{action_list}]. +{action_instructions} + +Please only generate a JSON string including the action type and the argument. +Your action should follow the given format: +{format_instructions} +""" + + +class SocialGame(ParallelSotopiaEnv, ABC): + """Abstract base class for social games. + + Defines the interface for building state, handling transitions, and building observations. + """ + + def __init__( + self, + env_profile: EnvironmentProfile, + action_handler: ActionHandler | None = None, + **kwargs: Any, + ) -> None: + super().__init__(env_profile=env_profile, **kwargs) + self.action_handler = action_handler + + @abstractmethod + def build_state(self, actions: Dict[str, AgentAction]) -> None: + """Update game state based on agent actions.""" + pass + + @abstractmethod + def state_transition(self) -> None: + """Handle state transitions (e.g., FSM updates).""" + pass + + @abstractmethod + def build_observation(self) -> Dict[str, Observation]: + """Generate observations for each agent.""" + pass + + async def astep( + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] + ) -> tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + """Process one step: record actions, update state, build observations.""" + self.turn_number += 1 + + # 1. Normalize actions and record to history + normalized_actions = self._process_incoming_actions(actions) + + # 2. Build State (Update internal state, process actions, check eliminations, update masks) + self.build_state(normalized_actions) + + # 3. State Transition (Check conditions, move FSM) + self.state_transition() + + # 4. Run evaluators (Moved to check post-transition state) + evaluator_response = await self._run_evaluators(self.evaluators) + + # 5. Build Observation (Generate what agents see) + observations = self.build_observation() + # 6. Set termination + terminated = {agent: evaluator_response.terminated for agent in self.agents} -class SocialGameEnv(ParallelSotopiaEnv): + # 7. Terminal evaluators + if evaluator_response.terminated and self.terminal_evaluators: + terminal_response = await self._run_evaluators(self.terminal_evaluators) + if evaluator_response.comments and terminal_response.comments: + evaluator_response.comments += terminal_response.comments + elif terminal_response.comments: + evaluator_response.comments = terminal_response.comments + + rewards = {agent: 0.0 for agent in self.agents} + truncations = {agent: False for agent in self.agents} + info = { + agent: {"comments": evaluator_response.comments or "", "complete_rating": 0} + for agent in self.agents + } + + return observations, rewards, terminated, truncations, info + + async def _run_evaluators(self, evaluators: list[Evaluator]) -> Any: + """Run evaluators and aggregate results""" + return unweighted_aggregate_evaluate( + list( + itertools.chain( + *await asyncio.gather( + *[ + evaluator.__acall__( + turn_number=self.turn_number, + messages=self.inbox, + env=self, + ) + for evaluator in evaluators + ] + ) + ) + ) + ) + + +class ActionHandler(ABC): + """Abstract base class for handling game-specific actions.""" + + @abstractmethod + def handle_action( + self, env: SocialDeductionGame, agent_name: str, action: AgentAction + ) -> None: + """Handle a single action from an agent based on current state. + + Args: + env: The game environment instance. + agent_name: The name of the agent performing the action. + action: The action object. + """ + pass + + def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> str: + """Get specific action instructions for an agent based on current state. + + Args: + env: The game environment instance. + agent_name: The name of the agent. + + Returns: + A string containing instructions, or empty string. + """ + return "" + + +class SocialDeductionGame(SocialGame): """Environment for social deduction games with states, roles, and private information. - Adds to ParallelSotopiaEnv: + Adds to SocialGame: - FSM states (Night, Day, etc.) - Role/team system - Alive/dead status @@ -31,28 +179,38 @@ def __init__( self, env_profile: EnvironmentProfile, *, - config_path: str, + config_path: str | None = None, + config: Dict[str, Any] | None = None, **kwargs: Any, ) -> None: - super().__init__(env_profile=env_profile, **kwargs) + super().__init__(env_profile=env_profile, include_turn_marker=False, **kwargs) # Load game configuration - self._config_path = Path(config_path) - self._config: Dict[str, Any] = {} + self._config_path = Path(config_path) if config_path else None + self._config: Dict[str, Any] = config if config else {} + + # Agent message buffer + self.agent_message_buffer: Dict[str, List[str]] = defaultdict(list) # Game state self.current_state: str = "" self.agent_to_role: Dict[str, str] = {} # Aurora -> Villager - self.role_to_team: Dict[str, str] = {} # Villager -> Villagers + self.role_to_team: Dict[ + str, str + ] = {} # Seer -> Villagers, Werewolf -> Werewolves self.agent_alive: Dict[str, bool] = {} # Aurora -> True self.internal_state: Dict[str, Any] = {} # votes, targets, etc. def _load_config(self) -> None: - """Load game configuration from JSON file.""" - if not self._config_path.exists(): - raise FileNotFoundError(f"Config not found: {self._config_path}") - - self._config = json.loads(self._config_path.read_text()) + """Load game configuration from JSON file if not already loaded.""" + if self._config: + pass + elif self._config_path: + if not self._config_path.exists(): + raise FileNotFoundError(f"Config not found: {self._config_path}") + self._config = json.loads(self._config_path.read_text()) + else: + raise ValueError("Neither config nor config_path provided") # Build role -> team mapping self.role_to_team = {} @@ -62,6 +220,45 @@ def _load_config(self) -> None: if role and team: self.role_to_team.setdefault(role, team) + async def astep( + self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] + ) -> tuple[ + Dict[str, Observation], + Dict[str, float], + Dict[str, bool], + Dict[str, bool], + Dict[str, Dict[Any, Any]], + ]: + """Process one step: update state counters and delegate.""" + # Update state turn counter + if not hasattr(self, "_state_turn_count"): + self._state_turn_count: Dict[str, int] = {} + + if self.current_state not in self._state_turn_count: + self._state_turn_count[self.current_state] = 0 + + self._state_turn_count[self.current_state] += 1 + + # Call super().astep to get results + ( + observations, + rewards, + terminated, + truncations, + info, + ) = await super().astep(actions) + + # Log termination + if all(terminated.values()): + # Extract comments/reasons from info + first_agent = list(self.agents)[0] + reason = info.get(first_agent, {}).get("comments", "Unknown reason") + + # Try to extract winner from reason if possible, otherwise just print reason + logger.info(f"Game Ends: {reason}") + + return observations, rewards, terminated, truncations, info + def reset( self, seed: int | None = None, @@ -69,11 +266,16 @@ def reset( agents: Agents | None = None, omniscient: bool = False, lite: bool = False, - ) -> Dict[str, Observation]: + include_background_observations: bool = True, + ) -> dict[str, Observation]: """Reset environment and initialize game state.""" # Call parent reset base_obs = super().reset( - seed=seed, options=options, agents=agents, omniscient=omniscient, lite=lite + seed=seed, + options=options, + agents=agents, + omniscient=omniscient, + include_background_observations=False, ) # Load config @@ -84,30 +286,28 @@ def reset( for name in self.agents: role = next( ( - a.get("role", "Villager") + a.get("role", "Unknown") for a in self._config.get("agents", []) if a.get("name") == name.strip() ), - "Villager", + "Unknown", ) self.agent_to_role[name] = role # Initialize alive status and state self.agent_alive = {name: True for name in self.agents} - self.current_state = self._config.get("initial_state", "Day_discussion") + self.current_state = self._config.get("initial_state", "Unknown") self.internal_state = {} + self._state_turn_count = {self.current_state: 0} # Send initial system message + initial_msg_content = f"[Game] State: {self.current_state}. The game begins!" + logger.info(initial_msg_content) self.recv_message( "Environment", - SimpleMessage( - message=f"[Game] State: {self.current_state}. The game begins!" - ), + SimpleMessage(message=initial_msg_content), ) - # Initialize round-robin counter - self._round_robin_idx = 0 - # Initialize action mask for first turn based on state self._update_action_mask() @@ -116,141 +316,54 @@ def reset( base_obs[agent_name].available_actions = self._get_available_actions( agent_name ) - - return base_obs - - async def astep( - self, actions: Dict[str, AgentAction] | Dict[str, Dict[str, int | str]] - ) -> tuple[ - Dict[str, Observation], - Dict[str, float], - Dict[str, bool], - Dict[str, bool], - Dict[str, Dict[Any, Any]], - ]: - """Process one step: record actions, update state, build observations.""" - self.turn_number += 1 - - # 1. Normalize actions to AgentAction objects - normalized_actions: Dict[str, AgentAction] = {} - for agent_name, action in actions.items(): - if isinstance(action, AgentAction): - normalized_actions[agent_name] = action - else: - # Convert dict to AgentAction - action_type = self.available_action_types[ - int(action.get("action_type", 0)) - ] - normalized_actions[agent_name] = AgentAction( - action_type=action_type, - argument=str(action.get("argument", "")), + # Inject initial action instruction if handler is present + if self.action_handler: + instruction = self.action_handler.get_action_instruction( + self, agent_name ) + if instruction: + base_obs[agent_name].action_instruction = instruction - # 2. Record actions to message history - self.recv_message( - "Environment", SimpleMessage(message=f"Turn #{self.turn_number}") - ) - for idx, (agent_name, action) in enumerate(normalized_actions.items()): - # Only record actions from agents who were allowed to act - if self.agent_alive.get(agent_name, False) and self.action_mask[idx]: - self.recv_message(agent_name, action) - - # 3. Run evaluators to check if game should terminate (e.g., max turns) - evaluator_response = unweighted_aggregate_evaluate( - list( - itertools.chain( - *await asyncio.gather( - *[ - evaluator.__acall__( - turn_number=self.turn_number, - messages=self.inbox, - env=self, - ) - for evaluator in self.evaluators - ] - ) - ) - ) - ) + return base_obs - # 4. Process game-specific logic - self._process_actions(normalized_actions) + def build_state(self, actions: Dict[str, AgentAction]) -> None: + """Update game state based on agent actions.""" + # 1. Process game-specific logic + self._process_actions(actions) - # 5. Check for eliminations + # 2. Check for eliminations self._check_eliminations() - # 6. Check if state should transition + def state_transition(self) -> None: + """Handle state transitions.""" should_transition = self._should_transition_state() - print( - f"DEBUG Turn {self.turn_number}: state={self.current_state}, should_transition={should_transition}, state_turn_count={getattr(self, '_state_turn_count', {})}" + logger.debug( + f"Turn {self.turn_number}: state={self.current_state}, should_transition={should_transition}, state_turn_count={getattr(self, '_state_turn_count', {})}" ) if should_transition: - self._transition_state() - print(f"DEBUG: Transitioned to {self.current_state}") + self._perform_transition_state() + logger.debug(f"Transitioned to {self.current_state}") - # 7. Update action mask for next turn based on state + # Update action mask for next turn based on (potentially new) state state_props = self._config.get("state_properties", {}).get( self.current_state, {} ) action_order = state_props.get("action_order", self.action_order) - print( - f"DEBUG: About to update mask - state={self.current_state}, action_order={action_order}" + logger.debug( + f"About to update mask - state={self.current_state}, action_order={action_order}" ) self._update_action_mask() - print(f"DEBUG: After update_action_mask - mask={self.action_mask}") - - # 8. Build observations with visibility filtering - observations = self._build_observations() - - # 9. Set termination from evaluators (including game-specific win conditions) - terminated = {agent: evaluator_response.terminated for agent in self.agents} - - # 10. If terminated and terminal_evaluators exist, run them - if evaluator_response.terminated and self.terminal_evaluators: - terminal_response = unweighted_aggregate_evaluate( - list( - itertools.chain( - *await asyncio.gather( - *[ - evaluator.__acall__( - turn_number=self.turn_number, - messages=self.inbox, - env=self, - ) - for evaluator in self.terminal_evaluators - ] - ) - ) - ) - ) - # Merge terminal evaluator response - if evaluator_response.comments and terminal_response.comments: - evaluator_response.comments += terminal_response.comments - elif terminal_response.comments: - evaluator_response.comments = terminal_response.comments - - rewards = {agent: 0.0 for agent in self.agents} - truncations = {agent: False for agent in self.agents} - info = { - agent: {"comments": evaluator_response.comments or "", "complete_rating": 0} - for agent in self.agents - } + logger.debug(f"After update_action_mask - mask={self.action_mask}") - return observations, rewards, terminated, truncations, info + def build_observation(self) -> Dict[str, Observation]: + """Generate observations for each agent.""" + return self._build_observations() def _process_actions(self, actions: Dict[str, AgentAction]) -> None: - """Process actions based on current state (votes, kills, etc.).""" - state_props = self._config.get("state_properties", {}).get( - self.current_state, {} - ) - - # Example: collect votes in voting state - if "vote" in state_props.get("actions", []): + """Process actions by delegating to action_handler.""" + if self.action_handler: for agent_name, action in actions.items(): - if action.action_type == "action" and "vote" in action.argument.lower(): - # Parse vote target from argument - # Store in internal_state - pass + self.action_handler.handle_action(self, agent_name, action) def _check_eliminations(self) -> None: """Check if anyone should be eliminated (voted out, killed, etc.).""" @@ -315,15 +428,7 @@ def _should_transition_state(self) -> bool: acting_roles = state_props.get("acting_roles", []) action_order = state_props.get("action_order", self.action_order) - # Initialize turn counter for this state if needed - if not hasattr(self, "_state_turn_count"): - self._state_turn_count: Dict[str, int] = {} - if self.current_state not in self._state_turn_count: - self._state_turn_count[self.current_state] = 0 - - # Increment turn count for this state - self._state_turn_count[self.current_state] += 1 - turns_in_state = self._state_turn_count[self.current_state] + turns_in_state = self._state_turn_count.get(self.current_state, 0) # Determine how many agents should act in this state if acting_roles: @@ -348,7 +453,7 @@ def _should_transition_state(self) -> bool: return False - def _transition_state(self) -> None: + def _perform_transition_state(self) -> None: """Transition to next state based on FSM.""" state_transition = self._config.get("state_transition", {}) next_state = state_transition.get(self.current_state) @@ -363,65 +468,88 @@ def _transition_state(self) -> None: self._round_robin_idx = 0 self.recv_message( "Environment", - SimpleMessage( - message=f"[Game] Transitioning to state: {self.current_state}" - ), + SimpleMessage(message=f"[Game] Entering state: {self.current_state}"), ) + logger.info(f"{'-'* 50}\nTurn to {self.current_state}\n{'-'* 50}") def _build_observations(self) -> Dict[str, Observation]: """Build observations for each agent based on visibility rules.""" observations = {} - for i, agent_name in enumerate(self.agents): - # Get recent messages visible to this agent - visible_history = self._get_visible_history(agent_name) - - # Get available actions for this agent - available_actions = self._get_available_actions(agent_name) - - observations[agent_name] = Observation( - last_turn=visible_history, - turn_number=self.turn_number, - available_actions=available_actions, - ) + for agent_name in self.agents: + observations[agent_name] = self._get_observation(agent_name) return observations - def _get_visible_history(self, agent_name: str) -> str: - """Get message history visible to this agent based on visibility rules.""" + def recv_message( + self, sender: str, message: Message, receivers: List[str] | None = None + ) -> None: + """Receive a message and distribute it to agents based on visibility.""" + super().recv_message(sender, message) + + # Determine visibility for each agent state_props = self._config.get("state_properties", {}).get( self.current_state, {} ) visibility = state_props.get("visibility", "public") - visible_messages = [] + for agent_name in self.agents: + should_see = False - for sender, message in self.inbox[-10:]: # Last 10 messages - if sender == "Environment": - # Environment messages always visible - visible_messages.append(message.to_natural_language()) + # Check for explicit receivers + if receivers is not None: + if agent_name in receivers: + should_see = True elif visibility == "public": - # Public: everyone sees everything - visible_messages.append(f"{sender}: {message.to_natural_language()}") + should_see = True elif visibility == "team": - # Team: only see teammate messages sender_team = self.role_to_team.get( self.agent_to_role.get(sender, ""), "" ) viewer_team = self.role_to_team.get( self.agent_to_role.get(agent_name, ""), "" ) - if sender_team == viewer_team: - visible_messages.append( + should_see = sender_team == viewer_team + elif visibility == "private": + should_see = sender == agent_name + + # Environment messages should be public unless explicitly targeted + if sender == "Environment" and receivers is None: + should_see = True + + if should_see: + if sender == "Environment": + self.agent_message_buffer[agent_name].append( + message.to_natural_language() + ) + else: + self.agent_message_buffer[agent_name].append( f"{sender}: {message.to_natural_language()}" ) - elif visibility == "private": - # Private: only see own messages - if sender == agent_name: - visible_messages.append(f"You: {message.to_natural_language()}") - return ( - "\n".join(visible_messages) if visible_messages else "[No recent activity]" + def _get_observation(self, agent_name: str) -> Observation: + """Get observation for a specific agent.""" + # Get visible history from buffer + visible_history = "\n".join(self.agent_message_buffer[agent_name]) + + # Clear buffer after reading: Observation usually only sends new content; agent's memory handles accumulation. + self.agent_message_buffer[agent_name].clear() + + # Get available actions + available_actions = self._get_available_actions(agent_name) + + # Add specific action instructions if handler is present + action_instruction = "" + if self.action_handler: + instruction = self.action_handler.get_action_instruction(self, agent_name) + if instruction: + action_instruction = instruction + + return Observation( + last_turn=visible_history if visible_history else "[No recent activity]", + turn_number=self.turn_number, + available_actions=available_actions, + action_instruction=action_instruction, ) def _get_available_actions( @@ -443,6 +571,15 @@ def _get_available_actions( if agent_role not in acting_roles: return ["none"] + # Check action mask (for round-robin/random ordering) + if self.action_mask: + try: + agent_idx = self.agents.index(agent_name) + if not self.action_mask[agent_idx]: + return ["none"] + except ValueError: + pass # Should not happen if agent_name is valid + allowed = { "none", "speak", @@ -466,3 +603,11 @@ def get_agent_team(self, agent_name: str) -> str: """Get the team of an agent.""" role = self.get_agent_role(agent_name) return self.role_to_team.get(role, "Unknown") + + +def load_config(config_path: str | Path) -> Dict[str, Any]: + """Load game configuration from JSON file.""" + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Config not found: {path}") + return cast(Dict[str, Any], json.loads(path.read_text())) From 089b30c5dc35b0743b73953bffde4baa7b0782ba Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 22:07:20 -0500 Subject: [PATCH 14/21] Add Social Game Engine documentation --- docs/pages/experimental/_meta.json | 3 ++ docs/pages/experimental/index.mdx | 1 + docs/pages/experimental/social_game.mdx | 42 +++++++++++++++++++++ examples/experimental/werewolves/README.md | 43 ++++++++++++++++++++++ sotopia/envs/social_game.py | 2 + 5 files changed, 91 insertions(+) create mode 100644 docs/pages/experimental/social_game.mdx create mode 100644 examples/experimental/werewolves/README.md diff --git a/docs/pages/experimental/_meta.json b/docs/pages/experimental/_meta.json index 68a2f45e1..89121eb91 100644 --- a/docs/pages/experimental/_meta.json +++ b/docs/pages/experimental/_meta.json @@ -4,5 +4,8 @@ }, "agents": { "title": "Agents" + }, + "social_game": { + "title": "Social Game Engine" } } diff --git a/docs/pages/experimental/index.mdx b/docs/pages/experimental/index.mdx index 7bcbe8fd3..e00c55fbf 100644 --- a/docs/pages/experimental/index.mdx +++ b/docs/pages/experimental/index.mdx @@ -33,6 +33,7 @@ The experimental APIs are in different states: Here are the experimental APIs: - [Agents](/experimental/agents) (*implemented*): aact-based asynchronous agents that don't follow OpenAI Gym's turn-based formulation. +- [Social Game Engine](/experimental/social_game) (*implemented*): Engine for complex multi-agent social deduction games (e.g., Werewolves). - Engines (*planned*): aact-based asynchronous environment engines. This would include - [Orchestrator](https://github.com/sotopia-lab/sotopia/issues/231): an engine base class for engines that dictates the orders and turns of the agents. - [Evaluator](https://github.com/sotopia-lab/sotopia/issues/232): an engine base class for engines that evaluates the agents' performance. diff --git a/docs/pages/experimental/social_game.mdx b/docs/pages/experimental/social_game.mdx new file mode 100644 index 000000000..a135fec4b --- /dev/null +++ b/docs/pages/experimental/social_game.mdx @@ -0,0 +1,42 @@ +# Social Game Engine + +The Social Game Engine is a new experimental module in Sotopia designed for creating complex, multi-agent social simulations with structured phases, roles, and secret information. + +## Overview + +Unlike standard dyadic interactions, social games often involve: +- **Multiple Agents**: More than 2 agents interacting simultaneously. +- **Roles & Teams**: Agents have distinct roles (e.g., Villager, Werewolf) and conflicting goals. +- **Dynamic Eras/Phases**: Games progress through distinct states (e.g., Day Discussion, Night Action). +- **Private Information**: Agents have secrets and limited visibility of others' actions. + +## Key Classes + +### `SocialGame` +The abstract base class for any multiplayer social game. It handles: +- **Turn Management**: Supports `round-robin` (sequential) or `simultaneous` (parallel) actions. +- **State Transitions**: Manages the flow of the game through defined states. + +### `SocialDeductionGame` +A subclass specialized for games like Werewolves, Undercover, or Spyfall. +- **Action Masking**: Enforces who can act in which phase. +- **Visibility System**: Controls who sees what messages (Public, Team-only, Private). +- **Environment Notifications**: Automatically broadcasts state changes to all agents, ensuring valid context even during private phases. + +## Example: Duskmire Werewolves + +We have implemented a full working example of a 6-player Werewolves game using this engine. + + +You can find the code and run the example at `examples/experimental/werewolves`. +See the [README](https://github.com/sotopia-lab/sotopia/tree/main/examples/experimental/werewolves/README.md) in that directory for details. + + +### Key Features Demonstrated +- **Sequential Discussion**: Agents speak one-by-one during the day, referencing previous speakers. +- **Hidden Roles**: Role information is concealed upon elimination. +- **Complex Logic**: Seer inspections, Witch potions, and Werewolf voting integration. + +## Usage + +To use the Social Game engine, you typically subclass `SocialDeductionGame`, define your states in `config.json`, and implement custom `EnvironmentProfile`s. diff --git a/examples/experimental/werewolves/README.md b/examples/experimental/werewolves/README.md new file mode 100644 index 000000000..7adf42c66 --- /dev/null +++ b/examples/experimental/werewolves/README.md @@ -0,0 +1,43 @@ +# Duskmire Werewolves + +A text-based social deduction game built on top of `sotopia`. This experimental example demonstrates how to implement complex game phases (Day/Night), roles, and turn-based interactions using the Sotopia framework. + +## Overview + +In this 6-player game, players are assigned roles (Villager, Werewolf, Seer, Witch) and compete to eliminate the opposing team. + +- **Villagers**: Must identify and vote out Werewolves. +- **Werewolves**: Must deceive Villagers and eliminate them at night. +- **Seer**: Can inspect one player's role each night. +- **Witch**: Has one potion to save a victim and one to poison a suspect. + +## Features + +- **Sequential Discussion**: Utilizes `round-robin` action order during the day, ensuring agents speak one after another and can reference previous arguments. +- **Simultaneous Action**: Night phases and voting are simultaneous to preserve secrecy/fairness. +- **Global Event Notifications**: Players receive system messages about state transitions (e.g., "Entering Night Phase") regardless of their role visibility settings. +- **Safe Elimination**: Role information is hidden from players upon elimination to simulate realistic uncertainty (roles are only revealed in admin logs). + +## Running the Game + +1. Ensure you have the `sotopia` environment set up. +2. Run the main script: + ```bash + python examples/experimental/werewolves/main.py + ``` + *Note: Ensure your Redis server is running.* + +## Configuration + +The game is configured via `config.json`. Key settings include: + +- **`state_properties`**: Defines the phases (Day/Night). + - `action_order`: Set to `"round-robin"` for sequential phases (e.g., `Day_discussion`), or `"simultaneous"` for others (e.g., `Day_vote`). + - `visibility`: Controls who sees messages (`"public"`, `"team"`, `"private"`). +- **`agents`**: Defines the roster and roles. + +## Extending + +To modify the game logic, check: +- `main.py`: Handles game initialization and elimination logic (`_check_eliminations`). +- `config.json` and `sotopia/envs/social_game.py`: Adjusts game balance, roles, and state transitions. diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index c636d4706..a5ec74a28 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -173,6 +173,8 @@ class SocialDeductionGame(SocialGame): - Alive/dead status - Private information visibility - State transitions + - Turn management (round-robin vs simultaneous) + - Global Environment notifications (bypassing visibility filters) """ def __init__( From 4ce9d6cb80960787ffa29c213e3233e4dfaa18f9 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 22:10:03 -0500 Subject: [PATCH 15/21] Delete examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py --- ...gotiationArena_1_Buy_Sell_custom_models.py | 209 ------------------ 1 file changed, 209 deletions(-) delete mode 100644 examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py diff --git a/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py b/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py deleted file mode 100644 index d9f6b81bc..000000000 --- a/examples/experimental/negotiation_arena/NegotiationArena_1_Buy_Sell_custom_models.py +++ /dev/null @@ -1,209 +0,0 @@ -# Run this in terminal: redis-stack-server --dir ~/Sotopia/examples/experimental/negotiation_arena/redis-data -import redis -import os -import asyncio -from typing import Any, cast -from sotopia.database.persistent_profile import AgentProfile, EnvironmentProfile -from sotopia.samplers import UniformSampler -from sotopia.server import run_async_server -from sotopia.messages import Observation, AgentAction -from constants import ( - AGENT_ONE, - AGENT_TWO, - MONEY_TOKEN, - RESOURCES_TAG, - GOALS_TAG, - PLAYER_ANSWER_TAG, - PROPOSED_TRADE_TAG, - ACCEPTING_TAG, - REJECTION_TAG, - MESSAGE_TAG, - PROPOSAL_COUNT_TAG, - REASONING_TAG, -) - - -client = redis.Redis(host="localhost", port=6379) - -os.environ["REDIS_OM_URL"] = "redis://:@localhost:6379" - - -def add_agent_to_database(**kwargs: Any) -> None: - agent = AgentProfile(**kwargs) - agent.save() - - -def add_env_profile(**kwargs: Any) -> None: - env_profile = EnvironmentProfile(**kwargs) - env_profile.save() - - -try: - alice = cast( - AgentProfile, - AgentProfile.find( - AgentProfile.first_name == "Red", AgentProfile.last_name == "Player" - ).all()[0], - ) -except (IndexError, NotImplementedError): - print("Agent not found, creating new agent profiles.") - add_agent_to_database( - first_name="Red", - last_name="Player", - age=30, - occupation="", - gender="", - gender_pronoun="", - big_five="", - moral_values=[], - decision_making_style="", - secret="", - ) - alice = cast( - AgentProfile, - AgentProfile.find( - AgentProfile.first_name == "Red", AgentProfile.last_name == "Player" - ).all()[0], - ) - -try: - bob = cast( - AgentProfile, - AgentProfile.find( - AgentProfile.first_name == "Blue", AgentProfile.last_name == "Player" - ).all()[0], - ) -except (IndexError, NotImplementedError): - print("Agent not found, creating new agent profiles.") - add_agent_to_database( - first_name="Blue", - last_name="Player", - age=30, - occupation="", - gender="", - gender_pronoun="", - big_five="", - moral_values=[], - decision_making_style="", - secret="", - ) - bob = cast( - AgentProfile, - AgentProfile.find( - AgentProfile.first_name == "Blue", AgentProfile.last_name == "Player" - ).all()[0], - ) - -scenario = "Player RED is going to sell one object. Player BLUE gives ZUP to buy resources." # @param {type:"string"} - - -def buy_sell_prompt( - resources_available_in_game: str, - starting_initial_resources: str, - player_goal: str, - maximum_number_of_proposals: int, - player_social_behaviour: str, -) -> str: - prompt = f"""You are playing game where you are buying or selling an object. There is only one object for sale/purcahse. - -{AGENT_ONE} is going to sell one object. {AGENT_TWO} gives {MONEY_TOKEN} to buy resources. - -RULES: - -``` -1. You must always respond with: - - A) Propose a trade with (you can only trade in integer amounts, not decimals): - <{PLAYER_ANSWER_TAG}> PROPOSAL - <{PROPOSED_TRADE_TAG}> {AGENT_ONE} Gives {resources_available_in_game}: amount, ...| {AGENT_TWO} Gives {MONEY_TOKEN}: amount - - B) Accept the trade by saying: - <{PLAYER_ANSWER_TAG}> {ACCEPTING_TAG} - <{PROPOSED_TRADE_TAG}> NONE - - C) Reject and end the game: - <{PLAYER_ANSWER_TAG}> {REJECTION_TAG} - <{PROPOSED_TRADE_TAG}> NONE - - Note: The game will end if one of the players {ACCEPTING_TAG} OR {REJECTION_TAG}. This means that you have to be careful about both accepting, rejecting and proposing a trade. - -2. You are allowed at most {maximum_number_of_proposals} proposals of your own to complete the game, after which you can only reply with {ACCEPTING_TAG} or {REJECTION_TAG}. -DO NOT propose a new trade after {maximum_number_of_proposals} proposals. Your limit for proposals is {maximum_number_of_proposals}. - -3. At each turn send messages to each other by using the following format: - -<{MESSAGE_TAG}>your message here - -You can decide if you want disclose your resources, goals, cost and willingness to pay in the message. -``` - -Here is what you have access to: -``` -Object that is being bought/sold: {resources_available_in_game} -<{RESOURCES_TAG}> {starting_initial_resources} -<{GOALS_TAG}> {player_goal} , -``` - -All the responses you send should contain the following and in this order: - -``` -<{PROPOSAL_COUNT_TAG}> [add here (inclusive of current)] -<{RESOURCES_TAG}> [add here] -<{GOALS_TAG}> [add here] -<{REASONING_TAG}> [add here] -<{PLAYER_ANSWER_TAG}> [add here] -<{PROPOSED_TRADE_TAG}> [add here] -<{MESSAGE_TAG}> [add here] None: - await run_async_server( - model_dict={ - "env": "custom/google/gemma-3-27b@http://127.0.0.1:1234/v1", - "agent1": "custom/qwen/qwen3-1.7b@http://127.0.0.1:1234/v1", - "agent2": "custom/qwen/qwen3-1.7b@http://127.0.0.1:1234/v1", - }, - sampler=sampler, - ) - - -if __name__ == "__main__": - asyncio.run(main()) From 39f46cdb963967a7ff75c8020b691678eefb0e41 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 22:14:32 -0500 Subject: [PATCH 16/21] Restore sotopia/cli/install/redis-data/dump.rdb to match origin/main --- sotopia/cli/install/redis-data/dump.rdb | Bin 502359 -> 7547 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/sotopia/cli/install/redis-data/dump.rdb b/sotopia/cli/install/redis-data/dump.rdb index b5d54d63a9c0cbd11069397cc9494c58cb9b3402..a69d20f841145abfc518a49357728da130f5a366 100644 GIT binary patch delta 389 zcmV;00eb$|)E@gfkQpg5`U-MoWNC9PVrg`9z(D#1V{~b4Wx^SC&S&}vb#rB8Ep26O z!U)JUkwPf}K9gZmIFlz24FL*~uOhRg0KJ^_jAT&4O0QL91Mj4vr`gT0kiZD zUjwtO6I}!W4wwGG1!w{QF_Yg4Czqq@1v3HwKa*inFqhk^1vmx(!$3qpOp_rDIG5*+ z12vaH_5o=E06&wk1vQuR~(EW;8Z5IAvlmG&g2A zHZ)^nI5J{3F=I7lW@a#9IWaIfIAUgEV`eipm&z9fbp-&!JU~N}Aq*3fJAG1@?*9d9 z0{}omlVMUgOKEOoWq3MhVRL9GFlIG4W@IriG&wP2F*GwbVPRr4Ha0e8VKFr@V>UQq jV>B^kF)%YU_kpp;ZOWIl&;bGh0ssL26cxdLEJDH7zQBo= literal 502359 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

w-6pPp%rG#?M-niL3EjP+ZNw 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 R02psDJo&vde| Date: Sat, 6 Dec 2025 22:23:40 -0500 Subject: [PATCH 17/21] Revert unnessarily changes in the uniform_sample and server.py --- sotopia/samplers/uniform_sampler.py | 21 ++---- sotopia/server.py | 110 ++++++++-------------------- 2 files changed, 39 insertions(+), 92 deletions(-) diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index 17d11abfc..c1e561777 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -1,19 +1,16 @@ -import logging import random from typing import Any, Generator, Type, TypeVar from sotopia.agents.base_agent import BaseAgent from sotopia.database import AgentProfile, EnvironmentProfile from sotopia.envs.parallel import ParallelSotopiaEnv -from sotopia.envs import SocialDeductionGame +from sotopia.envs.multi_agent_parallel import MultiAgentSotopiaEnv from .base_sampler import BaseSampler, EnvAgentCombo ObsType = TypeVar("ObsType") ActType = TypeVar("ActType") -logger = logging.getLogger(__name__) - class UniformSampler(BaseSampler[ObsType, ActType]): def sample( @@ -66,18 +63,14 @@ def sample( env_profile = random.choice(self.env_candidates) if isinstance(env_profile, str): env_profile = EnvironmentProfile.get(env_profile) - logger.info("Creating environment with %s agents", n_agent) - game_meta = getattr(env_profile, "game_metadata", None) or {} - env: ParallelSotopiaEnv - if game_meta.get("mode") == "social_game": - config_path = game_meta.get("config_path") - assert ( - config_path - ), "game_metadata.config_path is required for social_game" - env = SocialDeductionGame( - env_profile=env_profile, config_path=config_path, **env_params + # Use MultiAgentSotopiaEnv for more than 2 agents + if n_agent > 2: + print(f"Creating MultiAgentSotopiaEnv with {n_agent} agents") + env: ParallelSotopiaEnv = MultiAgentSotopiaEnv( + env_profile=env_profile, **env_params ) else: + print(f"Creating ParallelSotopiaEnv with {n_agent} agents") env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) agent_profile_candidates = self.agent_candidates diff --git a/sotopia/server.py b/sotopia/server.py index 309e8a56a..32c827052 100644 --- a/sotopia/server.py +++ b/sotopia/server.py @@ -1,7 +1,6 @@ import asyncio import itertools import logging -import re from typing import Literal, Sequence, Type, AsyncGenerator, Union import gin @@ -19,7 +18,7 @@ from sotopia.database import EpisodeLog, NonStreamingSimulationStatus, SotopiaDimensions from sotopia.envs import ParallelSotopiaEnv from sotopia.envs.evaluators import ( - EvaluationForAgents, + EvaluationForTwoAgents, EpisodeLLMEvaluator, RuleBasedTerminatedEvaluator, unweighted_aggregate_evaluate, @@ -63,13 +62,7 @@ def run_sync_server( else: environment_messages = env.reset() agents = Agents() - # agents_model_names = [model_name_dict["agent1"], model_name_dict["agent2"]] - # derive agent keys like agent1, agent2, … agentN - agent_keys = sorted(k for k in model_name_dict if re.fullmatch(r"agent\d+", k)) - agents_model_names = [model_name_dict[k] for k in agent_keys] - if len(agents_model_names) != len(env.agents): - raise ValueError("Number of agent models must match number of env agents") - + agents_model_names = [model_name_dict["agent1"], model_name_dict["agent2"]] for agent_name, agent_model in zip(env.agents, agents_model_names): if agent_model == "human": agents[agent_name] = HumanAgent(agent_name) @@ -159,44 +152,22 @@ async def generate_messages() -> ( while not done: # gather agent messages agent_messages: dict[str, AgentAction] = dict() - + actions = await asyncio.gather( + *[ + agents[agent_name].aact(environment_messages[agent_name]) + for agent_name in env.agents + ] + ) if script_like: - # Only call agents where action_mask is True + # manually mask one message agent_mask = env.action_mask - actions_to_gather = [] - acting_indices = [] - - for idx, agent_name in enumerate(env.agents): - if agent_mask[idx]: - actions_to_gather.append( - agents[agent_name].aact(environment_messages[agent_name]) - ) - acting_indices.append(idx) - - # Gather only acting agents' responses - if actions_to_gather: - acting_actions = await asyncio.gather(*actions_to_gather) - else: - acting_actions = [] - - # Build full actions list with "none" for non-acting agents - actions = [] - acting_idx = 0 - for idx in range(len(env.agents)): - if agent_mask[idx]: - actions.append(acting_actions[acting_idx]) - acting_idx += 1 + for idx in range(len(agent_mask)): + if agent_mask[idx] == 0: + actions[idx] = AgentAction(action_type="none", argument="") else: - actions.append(AgentAction(action_type="none", argument="")) - else: - # Original behavior: gather all agents - actions = await asyncio.gather( - *[ - agents[agent_name].aact(environment_messages[agent_name]) - for agent_name in env.agents - ] - ) + pass + # actions = cast(list[AgentAction], actions) for idx, agent_name in enumerate(env.agents): agent_messages[agent_name] = actions[idx] @@ -229,8 +200,7 @@ async def generate_messages() -> ( environment=env.profile.pk, agents=[agent.profile.pk for agent in agent_list], tag=tag, - # models=[env.model_name, agent_list[0].model_name, agent_list[1].model_name], - models=[env.model_name] + [agent.model_name for agent in agent_list], + models=[env.model_name, agent_list[0].model_name, agent_list[1].model_name], messages=[ [(m[0], m[1], m[2].to_natural_language()) for m in messages_in_turn] for messages_in_turn in messages @@ -273,7 +243,7 @@ async def generate_messages() -> ( @gin.configurable async def run_async_server( sampler: BaseSampler[Observation, AgentAction] = BaseSampler(), - action_order: Literal["simultaneous", "round-robin", "random"] = "round-robin", + action_order: Literal["simutaneous", "round-robin", "random"] = "round-robin", model_dict: dict[str, str] = {}, env_agent_combo_list: list[EnvAgentCombo[Observation, AgentAction]] = [], omniscient: bool = False, @@ -329,24 +299,15 @@ def get_agent_class( ], "terminal_evaluators": [ EpisodeLLMEvaluator( - model_dict.get("evaluator", model_dict["env"]), - EvaluationForAgents[SotopiaDimensions], + model_dict["env"], + EvaluationForTwoAgents[SotopiaDimensions], ), ], } - # agents_model_dict = { - # agent_name: model_name - # for agent_name, model_name in model_dict.items() - # if agent_name.startswith("agent") - # } - - agent_keys = sorted(k for k in model_dict if re.fullmatch(r"agent\d+", k)) - agent_models = [model_dict[k] for k in agent_keys] - agents_model_dict = { - f"agent{i+1}": model_name for i, model_name in enumerate(agent_models) + "agent1": model_dict["agent1"], + "agent2": model_dict["agent2"], } - env_agent_combo_iter = sampler.sample( agent_classes=[ get_agent_class(model_name) for model_name in agents_model_dict.values() @@ -404,10 +365,7 @@ async def arun_one_script( env.reset(agents=agents, omniscient=omniscient) agent_names = [agent.agent_name for agent in agent_list] - # assert len(agent_names) == 2, f"only support 2 agents, current: {agent_names}" - assert ( - agents and len(agents) >= 2 - ), "At least two agents required, current: {agent_names}" + assert len(agent_names) == 2, f"only support 2 agents, current: {agent_names}" script_background = env.inbox[0][1] assert isinstance(script_background, ScriptBackground) @@ -420,9 +378,9 @@ async def arun_one_script( env_message = [("Environment", script_background)] agent_messages = env_message + agent_messages - evaluator: EpisodeLLMEvaluator[SotopiaDimensions] = EpisodeLLMEvaluator( - model_name=model_dict.get("evaluator", model_dict["env"]), - response_format_class=EvaluationForAgents[SotopiaDimensions], + evaluator = EpisodeLLMEvaluator( + model_name="gpt-4", + response_format_class=EvaluationForTwoAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( list( @@ -440,11 +398,11 @@ async def arun_one_script( ) ) info: dict[str, dict[str, str | ScriptEnvironmentResponse | float | None]] = { - script_background.agent_names[0]: { + script_background.p1_name: { "comments": response.comments or "", "complete_rating": response.p1_rate or 0, # type: ignore }, - script_background.agent_names[1]: { + script_background.p2_name: { "comments": response.comments or "", "complete_rating": response.p2_rate or 0, # type: ignore }, @@ -454,18 +412,13 @@ async def arun_one_script( environment=env.profile.pk, agents=[agent.profile.pk for agent in agent_list], tag=tag, - models=[model_dict["env"]] - + [ - model_dict.get(f"agent{i+1}", model_dict.get("agent1", "")) - for i in range(len(agent_list)) - ], + models=[model_dict["env"], model_dict["agent1"], model_dict["agent2"]], messages=[ [(m[0], m[1], m[2].to_natural_language()) for m in messages_in_turn] for messages_in_turn in messages ], - reasoning="".join( - [str(info[agent]["comments"]) for agent in env.agents[:2]] - ), # Keep first 2 for compatibility + reasoning=str(info[env.agents[0]]["comments"]) + + str(info[env.agents[1]]["comments"]), rewards=[info[agent_name]["complete_rating"] for agent_name in env.agents], rewards_prompt=info["rewards_prompt"]["overall_prompt"], ) @@ -494,9 +447,9 @@ async def aevaluate_one_episode( push_to_db: bool = False, ) -> None: history = "\n".join(episode.render_for_humans()[1][:-2]) - evaluator: EpisodeLLMEvaluator[SotopiaDimensions] = EpisodeLLMEvaluator( + evaluator = EpisodeLLMEvaluator( model_name=model, - response_format_class=EvaluationForAgents[SotopiaDimensions], + response_format_class=EvaluationForTwoAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( list( @@ -507,6 +460,7 @@ async def aevaluate_one_episode( turn_number=-1, history=history, messages=None, + temperature=0.0, ) for single_evaluator in [evaluator] ] From f67623895165ddf0419080b6ae2112a947acc565 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 22:45:26 -0500 Subject: [PATCH 18/21] Minor update on werewolf prompt, Compatibility on uniform sampler and server --- sotopia/envs/social_game.py | 1 + sotopia/samplers/uniform_sampler.py | 13 +++---------- sotopia/server.py | 8 ++++---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index a5ec74a28..bcd70ed35 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -22,6 +22,7 @@ Imagine you are playing the game as {agent}. Here is the description of the game: {description} +Note: Player actions in the nights are not seen by players of other roles (e.g. only wolves can see other wolves' actions). Your ({agent}'s) goal: {goal} {secret} diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index c1e561777..dc2ec3ef3 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -4,7 +4,7 @@ from sotopia.agents.base_agent import BaseAgent from sotopia.database import AgentProfile, EnvironmentProfile from sotopia.envs.parallel import ParallelSotopiaEnv -from sotopia.envs.multi_agent_parallel import MultiAgentSotopiaEnv + from .base_sampler import BaseSampler, EnvAgentCombo @@ -63,15 +63,8 @@ def sample( env_profile = random.choice(self.env_candidates) if isinstance(env_profile, str): env_profile = EnvironmentProfile.get(env_profile) - # Use MultiAgentSotopiaEnv for more than 2 agents - if n_agent > 2: - print(f"Creating MultiAgentSotopiaEnv with {n_agent} agents") - env: ParallelSotopiaEnv = MultiAgentSotopiaEnv( - env_profile=env_profile, **env_params - ) - else: - print(f"Creating ParallelSotopiaEnv with {n_agent} agents") - env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) + print(f"Creating ParallelSotopiaEnv with {n_agent} agents") + env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) agent_profile_candidates = self.agent_candidates if len(agent_profile_candidates) == n_agent: diff --git a/sotopia/server.py b/sotopia/server.py index 32c827052..1f7b9832d 100644 --- a/sotopia/server.py +++ b/sotopia/server.py @@ -18,7 +18,7 @@ from sotopia.database import EpisodeLog, NonStreamingSimulationStatus, SotopiaDimensions from sotopia.envs import ParallelSotopiaEnv from sotopia.envs.evaluators import ( - EvaluationForTwoAgents, + EvaluationForAgents, EpisodeLLMEvaluator, RuleBasedTerminatedEvaluator, unweighted_aggregate_evaluate, @@ -300,7 +300,7 @@ def get_agent_class( "terminal_evaluators": [ EpisodeLLMEvaluator( model_dict["env"], - EvaluationForTwoAgents[SotopiaDimensions], + EvaluationForAgents[SotopiaDimensions], ), ], } @@ -380,7 +380,7 @@ async def arun_one_script( evaluator = EpisodeLLMEvaluator( model_name="gpt-4", - response_format_class=EvaluationForTwoAgents[SotopiaDimensions], + response_format_class=EvaluationForAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( list( @@ -449,7 +449,7 @@ async def aevaluate_one_episode( history = "\n".join(episode.render_for_humans()[1][:-2]) evaluator = EpisodeLLMEvaluator( model_name=model, - response_format_class=EvaluationForTwoAgents[SotopiaDimensions], + response_format_class=EvaluationForAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( list( From 67dc7db96e5d724611705bb311e6e8e071af977e Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 23:05:07 -0500 Subject: [PATCH 19/21] update uniform_sampler and server.py to the correct versions previous commit reverted too much.. --- sotopia/samplers/uniform_sampler.py | 6 ++-- sotopia/server.py | 51 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/sotopia/samplers/uniform_sampler.py b/sotopia/samplers/uniform_sampler.py index dc2ec3ef3..bf7a9e968 100644 --- a/sotopia/samplers/uniform_sampler.py +++ b/sotopia/samplers/uniform_sampler.py @@ -1,16 +1,18 @@ import random +import logging from typing import Any, Generator, Type, TypeVar from sotopia.agents.base_agent import BaseAgent from sotopia.database import AgentProfile, EnvironmentProfile from sotopia.envs.parallel import ParallelSotopiaEnv - from .base_sampler import BaseSampler, EnvAgentCombo ObsType = TypeVar("ObsType") ActType = TypeVar("ActType") +logger = logging.getLogger(__name__) + class UniformSampler(BaseSampler[ObsType, ActType]): def sample( @@ -63,7 +65,7 @@ def sample( env_profile = random.choice(self.env_candidates) if isinstance(env_profile, str): env_profile = EnvironmentProfile.get(env_profile) - print(f"Creating ParallelSotopiaEnv with {n_agent} agents") + logger.info("Creating ParallelSotopiaEnv with %s agents", n_agent) env = ParallelSotopiaEnv(env_profile=env_profile, **env_params) agent_profile_candidates = self.agent_candidates diff --git a/sotopia/server.py b/sotopia/server.py index 1f7b9832d..8705bad4b 100644 --- a/sotopia/server.py +++ b/sotopia/server.py @@ -1,6 +1,7 @@ import asyncio import itertools import logging +import re from typing import Literal, Sequence, Type, AsyncGenerator, Union import gin @@ -62,7 +63,12 @@ def run_sync_server( else: environment_messages = env.reset() agents = Agents() - agents_model_names = [model_name_dict["agent1"], model_name_dict["agent2"]] + # derive agent keys like agent1, agent2, … agentN + agent_keys = sorted(k for k in model_name_dict if re.fullmatch(r"agent\d+", k)) + agents_model_names = [model_name_dict[k] for k in agent_keys] + if len(agents_model_names) != len(env.agents): + raise ValueError("Number of agent models must match number of env agents") + for agent_name, agent_model in zip(env.agents, agents_model_names): if agent_model == "human": agents[agent_name] = HumanAgent(agent_name) @@ -152,6 +158,7 @@ async def generate_messages() -> ( while not done: # gather agent messages agent_messages: dict[str, AgentAction] = dict() + actions = await asyncio.gather( *[ agents[agent_name].aact(environment_messages[agent_name]) @@ -167,7 +174,6 @@ async def generate_messages() -> ( else: pass - # actions = cast(list[AgentAction], actions) for idx, agent_name in enumerate(env.agents): agent_messages[agent_name] = actions[idx] @@ -200,7 +206,7 @@ async def generate_messages() -> ( environment=env.profile.pk, agents=[agent.profile.pk for agent in agent_list], tag=tag, - models=[env.model_name, agent_list[0].model_name, agent_list[1].model_name], + models=[env.model_name] + [agent.model_name for agent in agent_list], messages=[ [(m[0], m[1], m[2].to_natural_language()) for m in messages_in_turn] for messages_in_turn in messages @@ -243,7 +249,7 @@ async def generate_messages() -> ( @gin.configurable async def run_async_server( sampler: BaseSampler[Observation, AgentAction] = BaseSampler(), - action_order: Literal["simutaneous", "round-robin", "random"] = "round-robin", + action_order: Literal["simultaneous", "round-robin", "random"] = "round-robin", model_dict: dict[str, str] = {}, env_agent_combo_list: list[EnvAgentCombo[Observation, AgentAction]] = [], omniscient: bool = False, @@ -299,15 +305,19 @@ def get_agent_class( ], "terminal_evaluators": [ EpisodeLLMEvaluator( - model_dict["env"], + model_dict.get("evaluator", model_dict["env"]), EvaluationForAgents[SotopiaDimensions], ), ], } + + agent_keys = sorted(k for k in model_dict if re.fullmatch(r"agent\d+", k)) + agent_models = [model_dict[k] for k in agent_keys] + agents_model_dict = { - "agent1": model_dict["agent1"], - "agent2": model_dict["agent2"], + f"agent{i+1}": model_name for i, model_name in enumerate(agent_models) } + env_agent_combo_iter = sampler.sample( agent_classes=[ get_agent_class(model_name) for model_name in agents_model_dict.values() @@ -365,7 +375,10 @@ async def arun_one_script( env.reset(agents=agents, omniscient=omniscient) agent_names = [agent.agent_name for agent in agent_list] - assert len(agent_names) == 2, f"only support 2 agents, current: {agent_names}" + # assert len(agent_names) == 2, f"only support 2 agents, current: {agent_names}" + assert ( + agents and len(agents) >= 2 + ), "At least two agents required, current: {agent_names}" script_background = env.inbox[0][1] assert isinstance(script_background, ScriptBackground) @@ -378,8 +391,8 @@ async def arun_one_script( env_message = [("Environment", script_background)] agent_messages = env_message + agent_messages - evaluator = EpisodeLLMEvaluator( - model_name="gpt-4", + evaluator: EpisodeLLMEvaluator[SotopiaDimensions] = EpisodeLLMEvaluator( + model_name=model_dict.get("evaluator", model_dict["env"]), response_format_class=EvaluationForAgents[SotopiaDimensions], ) response = unweighted_aggregate_evaluate( @@ -398,11 +411,11 @@ async def arun_one_script( ) ) info: dict[str, dict[str, str | ScriptEnvironmentResponse | float | None]] = { - script_background.p1_name: { + script_background.agent_names[0]: { "comments": response.comments or "", "complete_rating": response.p1_rate or 0, # type: ignore }, - script_background.p2_name: { + script_background.agent_names[1]: { "comments": response.comments or "", "complete_rating": response.p2_rate or 0, # type: ignore }, @@ -412,13 +425,18 @@ async def arun_one_script( environment=env.profile.pk, agents=[agent.profile.pk for agent in agent_list], tag=tag, - models=[model_dict["env"], model_dict["agent1"], model_dict["agent2"]], + models=[model_dict["env"]] + + [ + model_dict.get(f"agent{i+1}", model_dict.get("agent1", "")) + for i in range(len(agent_list)) + ], messages=[ [(m[0], m[1], m[2].to_natural_language()) for m in messages_in_turn] for messages_in_turn in messages ], - reasoning=str(info[env.agents[0]]["comments"]) - + str(info[env.agents[1]]["comments"]), + reasoning="".join( + [str(info[agent]["comments"]) for agent in env.agents[:2]] + ), # Keep first 2 for compatibility rewards=[info[agent_name]["complete_rating"] for agent_name in env.agents], rewards_prompt=info["rewards_prompt"]["overall_prompt"], ) @@ -447,7 +465,7 @@ async def aevaluate_one_episode( push_to_db: bool = False, ) -> None: history = "\n".join(episode.render_for_humans()[1][:-2]) - evaluator = EpisodeLLMEvaluator( + evaluator: EpisodeLLMEvaluator[SotopiaDimensions] = EpisodeLLMEvaluator( model_name=model, response_format_class=EvaluationForAgents[SotopiaDimensions], ) @@ -460,7 +478,6 @@ async def aevaluate_one_episode( turn_number=-1, history=history, messages=None, - temperature=0.0, ) for single_evaluator in [evaluator] ] From d48f71dbf9a75316fec65f78964a5c1563da78d5 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sat, 6 Dec 2025 23:07:34 -0500 Subject: [PATCH 20/21] move visibility prompt inside werewolf game's config --- examples/experimental/werewolves/config.json | 2 +- sotopia/envs/social_game.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index ad2a8da7e..345a94e4b 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -1,6 +1,6 @@ { "scenario": "Werewolves Game", - "description": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players go *one* round of public discussion to discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. If 'action' is available, use commands like 'kill NAME', 'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Day discussion is public. Voting requires an 'action' beginning with 'vote'.\n\n The players in this game are: {agent_names}, and during the day, they speak in the same order as they are listed.", + "description": "This game is called Werewolves (also known as Mafia), which is a social deduction game. GAME STRUCTURE: This game contains six players: two villagers, two werewolves, one seer, and one witch. Each cycle consists of Night phases followed by Day phases. WIN CONDITIONS: Villagers win by eliminating all Werewolves. Werewolves win when they equal or outnumber the Villagers. NIGHT PHASES: (1) Werewolves wake and privately choose one Villager to kill using 'kill NAME'. (2) The Seer wakes and inspects one player using 'inspect NAME' to learn if they are a Werewolf or Villager. (3) The Witch wakes and may use their one-time save potion with 'save NAME' (if someone died tonight) or their one-time poison potion with 'poison NAME' to kill someone. DAY PHASES: (1) Dawn: All players learn who died during the night (if any). (2) Discussion: All living players go *one* round of public discussion to discuss and debate who might be a Werewolf. Use 'speak' to share your thoughts and suspicions. (3) Voting: Each player votes to eliminate one suspected Werewolf using 'vote NAME'. The player with the most votes is executed. (4) Twilight: The execution result is announced and night returns. COMMUNICATION RULES: All day discussions are public. Dead players cannot speak or act. If 'action' is available, use commands like 'kill NAME', 'inspect NAME', 'save NAME', 'poison NAME', or 'vote NAME'. Note: Player actions in the nights are not seen by players of other roles (e.g. only wolves can see other wolves' actions). Day discussion is public. Voting requires an 'action' beginning with 'vote'.\n\n The players in this game are: {agent_names}, and during the day, they speak in the same order as they are listed.", "role_goals": { "Villager": "Act openly and collaboratively to identify werewolves.", "Werewolf": "Deceive others, avoid detection, and eliminate villagers.", diff --git a/sotopia/envs/social_game.py b/sotopia/envs/social_game.py index bcd70ed35..a5ec74a28 100644 --- a/sotopia/envs/social_game.py +++ b/sotopia/envs/social_game.py @@ -22,7 +22,6 @@ Imagine you are playing the game as {agent}. Here is the description of the game: {description} -Note: Player actions in the nights are not seen by players of other roles (e.g. only wolves can see other wolves' actions). Your ({agent}'s) goal: {goal} {secret} From a74bdbe5d3b16088e0c1eeef393fb27799c0ddc1 Mon Sep 17 00:00:00 2001 From: Keyu He Date: Sun, 7 Dec 2025 02:24:30 -0500 Subject: [PATCH 21/21] Fix premature termination and LiteLLM schema validation errors Found and fix the evaluation and generation error on the negotiation arena examples. - **Termination Fix**: Updated `ParallelSotopiaEnv` to pass the `env` instance to evaluators. Modified `RuleBasedTerminatedEvaluator` to correctly count active agents using `env.agents` instead of relying solely on message history, which caused early termination in the first turn. - **LiteLLM Support**: Updated `generate.py` to handle OpenAI schema limitations. Added `_fix_schema` to convert `prefixItems` (tuples) to `items` (arrays) and set `strict=False` to support dynamic dictionary outputs (Evaluator maps) while preventing `BadRequestError`. --- sotopia/envs/evaluators.py | 10 +++++++-- sotopia/envs/parallel.py | 1 + sotopia/generation_utils/generate.py | 32 +++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/sotopia/envs/evaluators.py b/sotopia/envs/evaluators.py index 0c8d69ad0..fed816c63 100644 --- a/sotopia/envs/evaluators.py +++ b/sotopia/envs/evaluators.py @@ -110,10 +110,16 @@ def __call__( latest_action_by_agent[speaker] = msg.action_type # If we haven't observed any agent messages yet, do not terminate early - if observed_agents: + env = kwargs.get("env") + if env: + all_agents = set(env.agents) + else: + all_agents = observed_agents + + if all_agents: num_active_agents = sum( 1 - for agent in observed_agents + for agent in all_agents if latest_action_by_agent.get(agent, "speak") != "leave" ) else: diff --git a/sotopia/envs/parallel.py b/sotopia/envs/parallel.py index 277bc737a..cfcbb3cfe 100644 --- a/sotopia/envs/parallel.py +++ b/sotopia/envs/parallel.py @@ -384,6 +384,7 @@ async def _run_evaluators(self, evaluators: list[Evaluator]) -> Any: evaluator.__acall__( turn_number=self.turn_number, messages=self.inbox, + env=self, ) for evaluator in evaluators ] diff --git a/sotopia/generation_utils/generate.py b/sotopia/generation_utils/generate.py index 34d6b785a..e5ecdd1b8 100644 --- a/sotopia/generation_utils/generate.py +++ b/sotopia/generation_utils/generate.py @@ -114,13 +114,43 @@ async def format_bad_output( # Parse format_instructions to get the schema try: schema = json.loads(format_instructions) + + def _fix_schema(s: dict[str, Any]) -> None: + if s.get("type") == "array": + if "prefixItems" in s: + # OpenAI doesn't support prefixItems (tuple validation). + # Convert to items: {anyOf: [...]} to satisfy "items must be a schema object" + # This allows valid tuple elements but loses positional validation. + prefix_items = s.pop("prefixItems") + s["items"] = {"anyOf": prefix_items} + + if "items" in s and isinstance(s["items"], dict): + _fix_schema(s["items"]) + elif "items" in s and isinstance(s["items"], list): + # Should not happen after the fix above, but handle legacy cases if any + for item in s["items"]: + _fix_schema(item) + elif s.get("type") == "object": + if "properties" in s: + for prop in s["properties"].values(): + _fix_schema(prop) + if "additionalProperties" in s and isinstance( + s["additionalProperties"], dict + ): + _fix_schema(s["additionalProperties"]) + if "$defs" in s: + for def_schema in s["$defs"].values(): + _fix_schema(def_schema) + + _fix_schema(schema) + # Build proper json_schema response_format completion_kwargs["response_format"] = { "type": "json_schema", "json_schema": { "name": "reformatted_output", "schema": schema, - "strict": True, + "strict": False, }, } except json.JSONDecodeError: