"""Codenames multi-agent plugin: 4-player word guessing game. Implements MultiAgentSystemPlugin interface for Codenames board game. Uses shared local Qwen3 8B game-play model from avalon/llm.py. """ from __future__ import annotations import logging import random from typing import Any from watchdog_env.models import AgentTurn, MultiAgentConfig, MultiAgentState, MultiAgentStep from watchdog_env.plugins.base import ( MultiAgentSystemPlugin, append_to_conversation_log, get_conversation_log, ) from watchdog_env.plugins.codenames.codenames_config import CODENAMES_AGENTS, CodenamesConfig from watchdog_env.plugins.codenames.board_generator import ( generate_board, BoardAssignment, BoardGenerationError, ) from watchdog_env.plugins.codenames.game_state import CodenamesGameState from watchdog_env.plugins.codenames.agents import ( CodenamesAgent, create_agents, ClueAction, GuessAction, AgentActionError, ) logger = logging.getLogger(__name__) def _get_agent_display_name(agent_id: str) -> str: """Get display name for an agent ID.""" display_names = { "red_spymaster": "Red Spymaster", "red_operative": "Red Operative", "blue_spymaster": "Blue Spymaster", "blue_operative": "Blue Operative", } return display_names.get(agent_id, agent_id) class CodenamesPlugin(MultiAgentSystemPlugin): """Multi-agent Codenames plugin with 4 players (2 teams). Game flow: 1. Red Spymaster gives clue 2. Red Operative guesses (can make multiple guesses) 3. Blue Spymaster gives clue 4. Blue Operative guesses 5. Repeat until one team wins or max turns reached Win conditions: - Find all your team's words - Opponent hits the assassin Lose conditions: - Hit the assassin - Opponent finds all their words first """ def __init__(self) -> None: self._state = MultiAgentState() self._game_state: CodenamesGameState | None = None self._agents: dict[str, CodenamesAgent] = {} self._config: CodenamesConfig | None = None def get_game_id(self) -> str: return "codenames" def get_display_name(self) -> str: return "Codenames (4-player word game)" def get_default_config(self, level: int) -> CodenamesConfig: """Default config for the given difficulty level.""" return CodenamesConfig(complexity_level=level) def list_agent_ids(self) -> list[str]: return list(CODENAMES_AGENTS) def reset( self, seed: int | None = None, config: MultiAgentConfig | None = None, ) -> None: """Initialize a new game with the given seed and config.""" if seed is not None: random.seed(seed) # Parse config cfg = config if isinstance(config, CodenamesConfig) else CodenamesConfig() cfg.validate() self._config = cfg # Generate board (uses WATCHDOG_LLM_BACKEND for LLM selection) board = generate_board( seed=seed, complexity_level=cfg.complexity_level, red_words=cfg.red_words, blue_words=cfg.blue_words, neutral_words=cfg.neutral_words, assassin_words=cfg.assassin_words, ) # Initialize game state self._game_state = CodenamesGameState( board=board, current_team=cfg.starting_team, current_phase="clue", max_turns=cfg.max_turns, ) # Create agents (uses WATCHDOG_LLM_BACKEND for LLM selection) self._agents = create_agents() # Initialize plugin state with conversation_log (matching Cicero pattern) self._state = MultiAgentState( step_index=0, turns_so_far=[], config=cfg, done=False, conversation_log=[], metadata={ "game_id": "codenames", "board_words": board.words, "starting_team": cfg.starting_team, }, ) def get_state(self) -> MultiAgentState: return self._state def get_game_state(self) -> CodenamesGameState | None: """Get the internal Codenames game state (for testing/debugging).""" return self._game_state def generate_step(self, seed: int | None, step_index: int) -> MultiAgentStep: """Generate one step of the game. Each step is one agent's action: - Spymaster giving a clue - Operative making a guess (or passing) Multiple guess steps may occur in sequence for the same operative. """ if seed is not None: random.seed(seed) if self._game_state is None or self._config is None: # Return empty step if not initialized return MultiAgentStep( turns=[], done=True, step_index=step_index, game_id=self.get_game_id(), ) game = self._game_state # Check if game is already over if game.game_over: return self._finalize_step(step_index, done=True, message="Game already over") # Get current agent current_agent_id = game.get_current_agent_id() agent = self._agents.get(current_agent_id) if agent is None: return self._finalize_step(step_index, done=True, message=f"Unknown agent: {current_agent_id}") # Get agent's action action = agent.get_action(game) turns: list[AgentTurn] = [] display_name = _get_agent_display_name(current_agent_id) if game.current_phase == "clue": # Spymaster giving clue if isinstance(action, ClueAction): game.process_clue(action.clue_word, action.clue_number, action.reasoning) action_text = f"CLUE: \"{action.clue_word}\" {action.clue_number}" if action.reasoning: action_text += f" (Reasoning: {action.reasoning})" turn = AgentTurn( agent_id=current_agent_id, action_text=action_text, step_index=step_index, phase="clue", display_name=display_name, metadata={ "clue_word": action.clue_word, "clue_number": action.clue_number, "reasoning": action.reasoning, "team": game.current_team, "role": "Spymaster", "phase": "clue", }, ) turns.append(turn) # Add to conversation log (matching Cicero pattern) append_to_conversation_log( self._state, speaker_id=current_agent_id, speaker_display=display_name, message=action_text, phase="clue", team=game.current_team, ) else: # guess phase # Operative making guess if isinstance(action, GuessAction): if action.pass_turn: # Operative passes game.pass_turn() action_text = f"PASS: Ending turn. (Reasoning: {action.reasoning})" turn = AgentTurn( agent_id=current_agent_id, action_text=action_text, step_index=step_index, phase="guess_pass", display_name=display_name, metadata={ "pass": True, "reasoning": action.reasoning, "team": agent.team, "role": "Operative", "phase": "guess_pass", }, ) turns.append(turn) append_to_conversation_log( self._state, speaker_id=current_agent_id, speaker_display=display_name, message=action_text, phase="guess_pass", team=agent.team, ) else: # Process the guess continue_guessing, result_message = game.process_guess( action.guessed_word, action.reasoning ) action_text = f"GUESS: \"{action.guessed_word}\" - {result_message}" if action.reasoning: action_text += f" (Reasoning: {action.reasoning})" turn = AgentTurn( agent_id=current_agent_id, action_text=action_text, step_index=step_index, phase="guess", display_name=display_name, metadata={ "guessed_word": action.guessed_word, "result": result_message, "reasoning": action.reasoning, "team": agent.team, "continue_guessing": continue_guessing, "role": "Operative", "phase": "guess", }, ) turns.append(turn) append_to_conversation_log( self._state, speaker_id=current_agent_id, speaker_display=display_name, message=action_text, phase="guess", team=agent.team, guessed_word=action.guessed_word, result=result_message, ) # If wrong guess or max guesses reached, end turn if not continue_guessing and not game.game_over: game.end_turn() # Update state self._state.step_index = step_index + 1 self._state.turns_so_far.extend(turns) self._state.done = game.game_over if game.game_over: self._state.metadata["winner"] = game.winner self._state.metadata["game_over_reason"] = game.game_over_reason return MultiAgentStep( turns=turns, done=game.game_over, step_index=step_index, game_id=self.get_game_id(), state=self._create_state_snapshot(), ) def _finalize_step(self, step_index: int, done: bool, message: str = "") -> MultiAgentStep: """Create a final step when game ends or error occurs.""" self._state.step_index = step_index + 1 self._state.done = done return MultiAgentStep( turns=[], done=done, step_index=step_index, game_id=self.get_game_id(), state=self._create_state_snapshot(), ) def _create_state_snapshot(self) -> MultiAgentState: """Create a snapshot of current state for the step output.""" return MultiAgentState( step_index=self._state.step_index, turns_so_far=list(self._state.turns_so_far), config=self._state.config, done=self._state.done, metadata=dict(self._state.metadata), conversation_log=list(self._state.conversation_log), ) def get_full_game_state(self) -> dict[str, Any]: """Get the complete serialized game state for recording.""" if self._game_state is None: return {} return { "game_state": self._game_state.to_dict(), "plugin_state": { "step_index": self._state.step_index, "done": self._state.done, "turns_count": len(self._state.turns_so_far), "metadata": self._state.metadata, }, }