[docs]@register_agent(AmongUsAgentConfig)classAmongUsAgent(AmongUsStateManagerMixin,MultiPlayerGameAgent[AmongUsAgentConfig]):"""Agent implementation for the Among Us game. This class inherits state management from AmongUsStateManagerMixin and agent behavior from MultiPlayerGameAgent. """
[docs]def__init__(self,config):"""Initialize the Among Us agent with configuration."""super().__init__(config)self.state_manager=self# Still point to self, but using mixin methodsself.ui=AmongUsUI()# Initialize the UI component
[docs]defvisualize_state(self,state:dict[str,Any]|AmongUsState)->None:"""Visualize the current game state using the AmongUsUI. This method is required by the MultiPlayerGameAgent parent class. Args: state: Current game state (dict or AmongUsState object) """# Ensure state is in the right formatifisinstance(state,dict):try:state_obj=AmongUsState(**state)exceptExceptionase:print(f"Error converting state dict to AmongUsState: {e}")returnelse:state_obj=state# Use the UI to display the gamedisplay=self.ui.display_game(state_obj)# Print the displayconsole=Console()console.print(display)
# Add this method to the AmongUsAgent class
[docs]defget_engine_for_player(self,role:str,engine_key:str)->Any|None:"""Get the appropriate engine for a player based on their role and the current. phase. Args: role: Player role (CREWMATE or IMPOSTOR) engine_key: Engine type key (player, meeting, voting) Returns: The appropriate engine runnable """# Convert PlayerRole enum to string if neededifisinstance(role,PlayerRole):role_str=role.nameelse:role_str=role.upper()# Check if role is validifrole_strnotin["CREWMATE","IMPOSTOR"]:logger.warning("Invalid role",extra={"role":role})returnNone# Get engines from src.configifnothasattr(self.config,"engines")ornotself.config.engines:logger.debug("No engines found in config")returnNone# Get engine for rolerole_engines=self.config.engines.get(role_str)ifnotrole_engines:logger.warning("No engines for role",extra={"role":role_str})returnNone# Get specific engineengine=role_engines.get(engine_key)ifnotengine:logger.warning("Engine not found",extra={"engine_key":engine_key,"role":role_str})returnNone# Create runnable if neededifhasattr(engine,"create_runnable"):returnengine.create_runnable()returnengine
[docs]defprepare_move_context(self,state:AmongUsState,player_id:str)->dict[str,Any]:"""Prepare context for a player's move decision."""ifplayer_idnotinstate.player_states:return{"error":f"Player {player_id} not found"}player_state=state.player_states[player_id]# Get filtered state for this playerfiltered_state=self.filter_state_for_player(state,player_id)# Add game-phase specific contextifstate.game_phase==AmongUsGamePhase.TASKS:# Add available actionsfiltered_state["available_actions"]=self.get_legal_moves(state,player_id)# Add role-specific informationifplayer_state.role==PlayerRole.CREWMATE:filtered_state["task_completion"]=(self._get_task_completion_percentage(state))else:# Impostorfiltered_state["potential_targets"]=self._get_potential_targets(state,player_id)filtered_state["kill_cooldown"]=getattr(self.config,"kill_cooldown",45)# Default 45sfiltered_state["fellow_impostors"]=[pidforpid,pstateinstate.player_states.items()ifpstate.role==PlayerRole.IMPOSTORandpid!=player_id]elifstate.game_phase==AmongUsGamePhase.MEETING:# Add meeting-specific contextfiltered_state["discussion_time"]=getattr(self.config,"discussion_time",45)filtered_state["alive_players"]=[pidforpid,pdatainstate.player_states.items()ifpdata.is_alive]# Add reason for meetingifstate.reported_body:filtered_state["reason"]="Body Reported"filtered_state["reported_body"]=state.reported_bodyifstate.reported_bodyinstate.player_states:filtered_state["body_location"]=state.player_states[state.reported_body].locationelse:filtered_state["reason"]="Emergency Meeting"filtered_state["reported_body"]=Noneelifstate.game_phase==AmongUsGamePhase.VOTING:# Add voting-specific contextfiltered_state["voting_time"]=getattr(self.config,"voting_time",30)filtered_state["alive_players"]=[pidforpid,pdatainstate.player_states.items()ifpdata.is_alive]filtered_state["voted_players"]=list(state.votes.keys())# Add discussion summaryifhasattr(state,"discussion_history")andstate.discussion_history:filtered_state["discussion_summary"]="\n".join([f"{msg['player_id']}: {msg['message']}"# Last 10 messagesformsginstate.discussion_history[-10:]])# Add game configuration informationfiltered_state["player_count"]=len(state.players)filtered_state["impostor_count"]=state.impostor_countfiltered_state["map_locations"]=state.map_locations# Always ensure player's own location is includedfiltered_state["location"]=player_state.location# Format tasks for prompttasks_str=[]fortaskinplayer_state.tasks:status="✓"iftask.status==TaskStatus.COMPLETEDelse"□"tasks_str.append(f"{status}{task.description} (in {task.location})")filtered_state["tasks"]="\n".join(tasks_str)# Format observationsifplayer_state.observations:filtered_state["observations"]="\n".join([f"• {obs}"forobsinplayer_state.observations])else:filtered_state["observations"]="None"# CRITICAL FIX: Add 'messages' field for the prompt template# Create a list with a single HumanMessage containing a formatted# situation description# Create a concise situation message based on the game phaseifstate.game_phase==AmongUsGamePhase.TASKS:situation=f"I am in {filtered_state['location']}. My tasks: {filtered_state['tasks']}."ifplayer_state.role==PlayerRole.IMPOSTOR:fellow=", ".join(filtered_state.get("fellow_impostors",[]))situation+=f" I am an impostor. Fellow impostors: {fellowor'none'}."else:situation+=f" Overall task completion: {filtered_state.get('task_completion',0)}%."elifstate.game_phase==AmongUsGamePhase.MEETING:situation=(f"Emergency meeting called by {filtered_state['meeting_caller']}!")iffiltered_state.get("reported_body"):situation+=f" Body of {filtered_state['reported_body']} was found."elifstate.game_phase==AmongUsGamePhase.VOTING:situation="It's time to vote! Discussion summary:\n"situation+=filtered_state.get("discussion_summary","No discussion recorded.")# Add observationssituation+=f"\nRecent observations: {filtered_state['observations']}"# Add message to contextfiltered_state["messages"]=[HumanMessage(content=situation)]returnfiltered_state
[docs]defextract_move(self,response:Any,role:str)->dict[str,Any]:"""Extract structured move from engine response."""# If response is already a structured dictionary, return itifisinstance(response,dict)and"action"inresponse:returnresponse# If response is a string, try to extract structured moveifisinstance(response,str):# Try to parse as JSONtry:parsed=json.loads(response)ifisinstance(parsed,dict)and"action"inparsed:returnparsedexceptBaseException:pass# Try to extract based on simple patternsif"move to"inresponse.lower():# Extract location from text like "I move to electrical"forlocationinself.config.map_locations:iflocation.lower()inresponse.lower():return{"action":"move","location":location}elif"complete task"inresponse.lower()or"do task"inresponse.lower():# Try to extract task ID from messagetask_match=re.search(r"task[_\s]*()",response,re.IGNORECASE)iftask_match:return{"action":"complete_task","task_id":task_match.group(1)}elif"kill"inresponse.lower():# Try to extract target from message like "I kill blue"forplayerinself.config.player_names:ifplayer.lower()inresponse.lower():return{"action":"kill","target_id":player}elif"report"inresponse.lower()or"body"inresponse.lower():return{"action":"report_body"}elif"emergency"inresponse.lower()or"meeting"inresponse.lower():return{"action":"call_emergency_meeting"}elif"vote"inresponse.lower():# Try to extract vote targetforplayerinself.config.player_names:ifplayer.lower()inresponse.lower():return{"action":"vote","vote_for":player}if"skip"inresponse.lower():return{"action":"vote","vote_for":"skip"}elif"sabotage"inresponse.lower():# Try to identify sabotage typesabotage_types={"light":"lights","oxygen":"o2","o2":"o2","reactor":"reactor","communication":"comms","comms":"comms",}forkey,valueinsabotage_types.items():ifkeyinresponse.lower():return{"action":"sabotage","sabotage_type":value,"location":value,}# If we couldn't parse a specific action, but we're in discussion# phaseif"discuss"inresponse.lower()orlen(response)>20:return{"action":"discuss","message":response}# Default fallback actionreturn{"action":"observe"}