diff --git a/examples/tool_usage_demo.py b/examples/tool_usage_demo.py
new file mode 100644
index 0000000..08138a1
--- /dev/null
+++ b/examples/tool_usage_demo.py
@@ -0,0 +1,205 @@
+"""
+Example script demonstrating the usage of agent tools.
+
+This script shows how to:
+1. Set up a mock game state
+2. Call individual tools for testing
+3. Demonstrate tool capabilities and access control
+
+Usage:
+ python examples/tool_usage_demo.py
+"""
+
+from unittest.mock import Mock
+from src.game.agents.tools.identity import (
+ update_player_mindset_tool,
+ analyze_speech_consistency,
+)
+from src.game.agents.tools.speech import generate_speech_tool
+from src.game.agents.tools.voting import decide_vote_tool, analyze_voting_patterns
+from src.game.state import (
+ PlayerPrivateState,
+ PlayerMindset,
+ SelfBelief,
+ Suspicion,
+ Vote,
+)
+
+
+def create_demo_game_state():
+ """Create a demo game state for testing."""
+ return {
+ "game_id": "demo-game-1",
+ "players": ["alice", "bob", "charlie"],
+ "current_round": 2,
+ "game_phase": "speaking",
+ "phase_id": "2:speaking:demo123",
+ "completed_speeches": [
+ {
+ "round": 1,
+ "seq": 0,
+ "player_id": "alice",
+ "content": "It's something sweet and fruity",
+ "ts": 1234567890000,
+ },
+ {
+ "round": 1,
+ "seq": 1,
+ "player_id": "bob",
+ "content": "It's red and crunchy",
+ "ts": 1234567891000,
+ },
+ {
+ "round": 1,
+ "seq": 2,
+ "player_id": "charlie",
+ "content": "It grows on trees",
+ "ts": 1234567892000,
+ },
+ ],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {"alice": "spy", "bob": "civilian", "charlie": "civilian"},
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "alice": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.6),
+ suspicions={},
+ ),
+ ),
+ "bob": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.75),
+ suspicions={
+ "alice": Suspicion(
+ role="spy",
+ confidence=0.65,
+ reason="Her description seems slightly off",
+ ),
+ },
+ ),
+ ),
+ "charlie": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.7),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+
+def demo_speech_consistency_analysis():
+ """Demo: Analyze speech consistency."""
+ print("\n" + "=" * 60)
+ print("DEMO: Speech Consistency Analysis")
+ print("=" * 60)
+
+ state = create_demo_game_state()
+
+ # Bob analyzes Alice's speeches
+ print("\n[Bob analyzing Alice's speeches...]")
+ result = analyze_speech_consistency(state, "bob", "alice")
+
+ print(f"\nAnalysis Results:")
+ print(f" Target: {result['target_player_id']}")
+ print(f" Speech Count: {result['speech_count']}")
+ print(f" Speeches: {result['target_speeches']}")
+ print(f" Average Length: {result['avg_speech_length']:.1f} characters")
+
+
+def demo_voting_patterns():
+ """Demo: Analyze voting patterns."""
+ print("\n" + "=" * 60)
+ print("DEMO: Voting Pattern Analysis")
+ print("=" * 60)
+
+ state = create_demo_game_state()
+ state["game_phase"] = "voting"
+ state["phase_id"] = "2:voting:demo456"
+
+ # Add some votes
+ state["current_votes"] = {
+ "alice": Vote(target="bob", ts=1234567900000, phase_id="2:voting:demo456"),
+ "bob": Vote(target="alice", ts=1234567901000, phase_id="2:voting:demo456"),
+ "charlie": Vote(target="alice", ts=1234567902000, phase_id="2:voting:demo456"),
+ }
+
+ print("\n[Analyzing voting patterns...]")
+ result = analyze_voting_patterns(state, "bob")
+
+ print(f"\nVoting Pattern Results:")
+ print(f" Total Votes: {result['total_votes']}")
+ print(f" Vote Distribution: {result['vote_distribution']}")
+ print(f" Most Voted Player: {result['most_voted_player']}")
+ print(f" Most Voted Count: {result['most_voted_count']}")
+ print(f" Is Bandwagon: {result['is_bandwagon']}")
+
+
+def demo_access_control():
+ """Demo: Access control validation."""
+ print("\n" + "=" * 60)
+ print("DEMO: Access Control Validation")
+ print("=" * 60)
+
+ state = create_demo_game_state()
+
+ # Try to analyze with invalid player ID
+ print("\n[Attempting to use invalid player ID...]")
+ try:
+ analyze_speech_consistency(state, "invalid_player", "alice")
+ print(" ❌ Access control FAILED - no error raised!")
+ except ValueError as e:
+ print(f" ✅ Access control PASSED - Error: {e}")
+
+ # Try to analyze invalid target
+ print("\n[Attempting to analyze invalid target...]")
+ try:
+ analyze_speech_consistency(state, "bob", "invalid_target")
+ print(" ❌ Access control FAILED - no error raised!")
+ except ValueError as e:
+ print(f" ✅ Access control PASSED - Error: {e}")
+
+
+def main():
+ """Run all demos."""
+ print("\n" + "=" * 60)
+ print("AGENT TOOLS USAGE DEMONSTRATION")
+ print("=" * 60)
+ print("\nThis demo shows the capabilities of the new agent tool library.")
+ print("Phase 1 implementation includes:")
+ print(" - Identity inference tools")
+ print(" - Speech analysis tools")
+ print(" - Voting decision tools")
+ print(" - Proper access control")
+
+ try:
+ demo_speech_consistency_analysis()
+ demo_voting_patterns()
+ demo_access_control()
+
+ print("\n" + "=" * 60)
+ print("DEMO COMPLETED SUCCESSFULLY")
+ print("=" * 60)
+ print("\nNext Steps:")
+ print(" 1. Run unit tests: pytest tests/agents/tools/")
+ print(" 2. Integrate tools into agent factories (Phase 2)")
+ print(" 3. Update graph nodes to use agents (Phase 3)")
+
+ except Exception as e:
+ print(f"\n❌ Demo failed with error: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/game/agents/__init__.py b/src/game/agents/__init__.py
new file mode 100644
index 0000000..10d7cbe
--- /dev/null
+++ b/src/game/agents/__init__.py
@@ -0,0 +1,27 @@
+"""
+Agent-based workflow orchestration for LieGraph.
+
+This module provides tools and utilities for agent-based gameplay:
+- Tool library for identity inference, speech generation, and voting
+- Agent state management with proper access control
+- Integration with LangGraph workflow
+
+Architecture:
+- Tools: Modular capabilities accessed via ToolRuntime
+- Access Control: Players can only access their own private state
+- State Management: Single GameState as the source of truth
+"""
+
+from .tools import (
+ update_player_mindset_tool,
+ generate_speech_tool,
+ analyze_speech_consistency,
+ decide_vote_tool,
+)
+
+__all__ = [
+ "update_player_mindset_tool",
+ "generate_speech_tool",
+ "analyze_speech_consistency",
+ "decide_vote_tool",
+]
diff --git a/src/game/agents/tools/__init__.py b/src/game/agents/tools/__init__.py
new file mode 100644
index 0000000..c05c6ee
--- /dev/null
+++ b/src/game/agents/tools/__init__.py
@@ -0,0 +1,23 @@
+"""
+Tool library for agent-based LLM workflow orchestration.
+
+This module provides tools for:
+- Identity inference and role analysis
+- Speech consistency analysis
+- Strategic speech generation
+- Vote decision support
+
+All tools follow the LangChain @tool pattern and use ToolRuntime
+for state access with proper player-level access control.
+"""
+
+from .identity import update_player_mindset_tool
+from .speech import generate_speech_tool, analyze_speech_consistency
+from .voting import decide_vote_tool
+
+__all__ = [
+ "update_player_mindset_tool",
+ "generate_speech_tool",
+ "analyze_speech_consistency",
+ "decide_vote_tool",
+]
diff --git a/src/game/agents/tools/identity.py b/src/game/agents/tools/identity.py
new file mode 100644
index 0000000..0e20970
--- /dev/null
+++ b/src/game/agents/tools/identity.py
@@ -0,0 +1,361 @@
+"""
+Identity inference and role analysis tools.
+
+This module provides tools for players to analyze game state
+and update their beliefs about their own role and other players' roles.
+
+Access Control:
+- Players can only access their own private state
+- Players can access public game state (speeches, votes, etc.)
+- Players cannot access other players' private states or host private state
+"""
+
+from typing import Any, Dict, List, Sequence
+
+from src.game.state import GameState, PlayerMindset, Speech, SelfBelief, Suspicion
+from trustcall import create_extractor
+
+
+def _format_players_xml(players: Sequence[str], alive: Sequence[str], me: str) -> str:
+ """Format player list as XML for prompt."""
+ from html import escape
+
+ alive_tags = "".join(
+ f'' for pid in alive
+ )
+ roster_tags = "".join(f'' for pid in players)
+ return (
+ f''
+ f"{alive_tags or ''}"
+ f"{roster_tags}"
+ ""
+ )
+
+
+def _trim_text_for_prompt(text: str, limit: int = 180) -> str:
+ """Normalize whitespace and trim long passages for prompt friendliness."""
+ cleaned = " ".join(str(text).split())
+ if len(cleaned) <= limit:
+ return cleaned
+ return cleaned[: limit - 1].rstrip() + "…"
+
+
+def _format_mindset_xml(playerMindset: PlayerMindset) -> str:
+ """Format player mindset as XML for prompt."""
+ from html import escape
+
+ self_belief = playerMindset.self_belief
+ suspicions = playerMindset.suspicions or {}
+ suspicions_tags = []
+ for pid, suspicion in suspicions.items():
+ trimmed_reason = _trim_text_for_prompt(suspicion.reason, limit=160)
+ suspicions_tags.append(
+ (
+ f''
+ f"{escape(trimmed_reason)}"
+ ""
+ )
+ )
+
+ suspicions_block = "".join(suspicions_tags) or ""
+ return (
+ f''
+ f"{suspicions_block}"
+ ""
+ )
+
+
+def _format_speeches_xml(
+ completed_speeches: Sequence[Speech],
+ rounds_to_keep: int | None = None,
+ max_entries: int | None = None,
+) -> str:
+ """Render speeches in chronological order with optional trimming."""
+ from html import escape
+
+ if not completed_speeches:
+ return ""
+
+ ordered = sorted(
+ completed_speeches,
+ key=lambda s: (s.get("round", 0), s.get("seq", 0)),
+ )
+
+ if rounds_to_keep is not None:
+ round_ids = sorted({speech.get("round", 0) for speech in ordered})
+ selected_rounds = set(round_ids[-rounds_to_keep:])
+ filtered = [
+ speech for speech in ordered if speech.get("round", 0) in selected_rounds
+ ]
+ else:
+ filtered = ordered
+
+ if max_entries is not None and len(filtered) > max_entries:
+ filtered = filtered[-max_entries:]
+
+ segments = [""]
+ current_round = None
+
+ for speech in filtered:
+ round_index = speech.get("round", 0)
+ if round_index != current_round:
+ if current_round is not None:
+ segments.append("")
+ segments.append(f'')
+ current_round = round_index
+
+ segments.append(
+ (
+ f''
+ f"{escape(_trim_text_for_prompt(speech.get("content", ""), limit=140))}"
+ ""
+ )
+ )
+
+ if current_round is not None:
+ segments.append("")
+
+ segments.append("")
+ return "".join(segments)
+
+
+def _build_inference_user_context(
+ completed_speeches: Sequence[Speech],
+ players: List[str],
+ alive: List[str],
+ me: str,
+ existing_player_mindset: PlayerMindset,
+) -> str:
+ """Build user context for inference prompt."""
+ players_xml = _format_players_xml(players, alive, me)
+ mindset_xml = _format_mindset_xml(existing_player_mindset)
+ speeches_xml = _format_speeches_xml(completed_speeches)
+
+ return (
+ ""
+ f"{players_xml}{mindset_xml}{speeches_xml}"
+ "Use the PlayerMindset tool only; do not provide prose or explanations."
+ ""
+ )
+
+
+_INFERENCE_PROMPT_PREFIX = """You are a player in the game "Who is the Spy". Your goal is to analyze the game state and update your beliefs.
+- Pay close attention to whether other players' descriptions match your understanding of your word.
+- If descriptions seem inconsistent with your word, you might be the Spy.
+- IMPORTANT: You MUST respond in the same language as the user's word, which is: "{my_word}".
+
+**Game Rules:**
+- Players: {player_count}, Spies: {spy_count}
+- Civilians get one word, the Spy gets a related one.
+- Your word is: "{my_word}"
+- You MUST respond in the same language as your word.
+
+**Your Task:**
+1. First, complete the `` block to structure your analysis.
+2. Then, based on your analysis, use the `PlayerMindset` tool to output your updated beliefs.
+
+---
+
+**1. Self-Role Analysis:**
+* **Evidence FOR being SPY:**
+ * **Group Consensus Conflict:** (Do multiple players' statements align with each other but conflict with your word "{my_word}"? List them.)
+ * **Other Inconsistencies:** (List any other statements that feel odd or point to a different concept.)
+* **Evidence FOR being CIVILIAN:**
+ * **Outlier Conflict:** (Did one player make a statement that conflicts with both your word AND other players' statements? Identify this outlier.)
+ * **Strong Alignment:** (List statements from others that perfectly match your word( "{my_word}".)
+ * **Conclusion:** (Based on the evidence above, are you more likely the Spy or a Civilian? State your conclusion in one sentence.)
+**2. Suspicion Analysis (for each other alive player):**
+* **Player [ID]:**
+ * **Evidence:** (Analyze their speech. Is it consistent with the group? Is it an outlier? Is it vague?)
+ * **Conclusion:** (Based on the evidence, are they likely a Spy or a Civilian?)
+ * **Player [ID]:**
+ * ... (Repeat for all other alive players)
+
+---
+
+**Decision & Confidence Rules:**
+- **Self-Role:**
+ - Treat strong conflicts (two or more players matching each other while clashing with your word) as a **major clue** that you might be the Spy.
+ - If the evidence is mixed, stay uncertain and keep probing; do **not** force a Spy conclusion if the group could still share your concept.
+- **Self-Confidence:**
+ - If you are convinced you are the **Spy**, set confidence to **0.8** (very certain but still cautious).
+ - If you lean Civilian and have a clear suspect, set confidence to **0.75**.
+ - If the evidence is ambiguous, keep confidence around **0.5–0.65** to reflect doubt.
+- **Suspicions:**
+ - Strong outliers (conflicting with both you and the group) are prime Spy candidates. Mark them as **Spy** with confidence **0.85**.
+ - Very vague speakers earn light suspicion. Mark them as **Spy** around **0.55**.
+ - Players aligned with the consensus should be tagged **Civilian** with confidence **0.75**.
+
+**Final Instruction:**
+Now, use the `PlayerMindset` tool to return the updated state based on your completed analysis. Do not provide any other text outside the tool output.
+"""
+
+
+def update_player_mindset_tool(
+ state: GameState,
+ player_id: str,
+) -> Dict[str, Any]:
+ """
+ Analyze game state and update player's beliefs about roles.
+
+ This tool performs identity inference by analyzing:
+ - Speech consistency across players
+ - Alignment with player's assigned word
+ - Detection of outliers and consensus
+
+ Access Control:
+ - Can only access player's own private state (player_private_states[player_id])
+ - Can access public game state (completed_speeches, players, etc.)
+ - Cannot access other players' private states or host_private_state
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player performing the analysis
+
+ Returns:
+ Dictionary with updated player_private_states
+ """
+
+ # Access control: validate player_id exists
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+
+ # Get player's private state (allowed)
+ player_private = state["player_private_states"][player_id]
+ my_word = player_private.assigned_word
+ existing_mindset = player_private.playerMindset
+
+ # Get public game state (allowed)
+ completed_speeches = state.get("completed_speeches", [])
+ players = state["players"]
+
+ # Get alive players from public state
+ eliminated = set(state.get("eliminated_players", []))
+ alive = [p for p in players if p not in eliminated]
+
+ # Get game rules from config
+ from src.game.config import get_config
+
+ config = get_config()
+ rules = config.get_game_rules()
+
+ # Build prompt context
+ system_prompt = _INFERENCE_PROMPT_PREFIX.format(
+ my_word=my_word, player_count=len(players), spy_count=rules.get("spy_count", 1)
+ )
+
+ user_context = _build_inference_user_context(
+ completed_speeches, players, alive, player_id, existing_mindset
+ )
+
+ # Call LLM with structured output extraction
+ from src.tools.llm import create_llm
+
+ llm_client = create_llm()
+
+ extractor = create_extractor(
+ llm_client, tools=[PlayerMindset], tool_choice="PlayerMindset"
+ )
+ result = extractor.invoke(
+ {"messages": [("system", system_prompt), ("user", user_context)]}
+ )
+
+ if result["responses"]:
+ new_mindset = result["responses"][0]
+
+ # Log the update
+ from src.game.llm_strategy import log_self_belief_update
+
+ log_self_belief_update(
+ player_id, existing_mindset.self_belief, new_mindset.self_belief
+ )
+
+ # Merge suspicions (preserve existing + add new)
+ from src.game.state import merge_probs, PlayerPrivateState
+
+ merged_suspicions = merge_probs(
+ existing_mindset.suspicions, new_mindset.suspicions
+ )
+
+ updated_private_state = PlayerPrivateState(
+ assigned_word=my_word,
+ playerMindset=PlayerMindset(
+ self_belief=new_mindset.self_belief,
+ suspicions=merged_suspicions,
+ ),
+ )
+
+ # Return update dictionary
+ return {"player_private_states": {player_id: updated_private_state}}
+ else:
+ # LLM failed, preserve existing mindset
+ from src.game.llm_strategy import log_self_belief_update
+
+ log_self_belief_update(
+ player_id, existing_mindset.self_belief, existing_mindset.self_belief
+ )
+
+ return {}
+
+
+def analyze_speech_consistency(
+ state: GameState,
+ player_id: str,
+ target_player_id: str,
+) -> Dict[str, Any]:
+ """
+ Analyze speech consistency of a target player.
+
+ This tool analyzes:
+ - Whether target's speeches align with group consensus
+ - Whether target is an outlier
+ - Speech vagueness and specificity
+
+ Access Control:
+ - Can only access public speeches
+ - Cannot access any private states
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player performing analysis
+ target_player_id: ID of the player to analyze
+
+ Returns:
+ Analysis results with consistency scores and patterns
+ """
+
+ # Access control: validate both player IDs
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+ if target_player_id not in state["players"]:
+ raise ValueError(f"Invalid target_player_id: {target_player_id}")
+
+ # Get target player's speeches (public data)
+ completed_speeches = state.get("completed_speeches", [])
+ target_speeches = [
+ s for s in completed_speeches if s.get("player_id") == target_player_id
+ ]
+
+ # Simple consistency analysis
+ # TODO: This is a placeholder - could be enhanced with LLM-based analysis
+ avg_length = sum(len(s.get("content", "")) for s in target_speeches) / max(
+ len(target_speeches), 1
+ )
+
+ analysis = {
+ "target_player_id": target_player_id,
+ "speech_count": len(target_speeches),
+ "speeches": [s.get("content", "") for s in target_speeches],
+ "target_speeches": [
+ s.get("content", "") for s in target_speeches
+ ], # Alias for consistency
+ "avg_speech_length": avg_length,
+ "is_outlier": False, # Placeholder logic
+ "vagueness_score": 0.5, # Placeholder score
+ }
+
+ return analysis
diff --git a/src/game/agents/tools/speech.py b/src/game/agents/tools/speech.py
new file mode 100644
index 0000000..c333c78
--- /dev/null
+++ b/src/game/agents/tools/speech.py
@@ -0,0 +1,338 @@
+"""
+Strategic speech generation tools.
+
+This module provides tools for generating player speeches
+based on their mindset, confidence, and game state.
+
+Access Control:
+- Players can only access their own private state
+- Players can access public game state
+"""
+
+import re
+from typing import Any, Sequence, Dict, List
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from src.game.state import (
+ GameState,
+ Speech,
+ SelfBelief,
+ Suspicion,
+ create_speech_record,
+)
+from html import escape
+
+
+def _determine_clarity(
+ role: str, self_confidence, current_round: int
+) -> tuple[str, str]:
+ """Return role-aware clarity code and description for the current round."""
+ if role == "spy" and self_confidence > 0.5:
+ if current_round <= 2:
+ return (
+ "low",
+ "LOW clarity — stay broad to blend with civilians",
+ )
+ if current_round <= 4:
+ return (
+ "medium",
+ "MEDIUM clarity — add safe overlaps without exposing differences",
+ )
+ return (
+ "medium",
+ "MEDIUM clarity — stay measured while matching the group's detail level",
+ )
+
+ # Civilian defaults
+ if current_round <= 1:
+ return "low", "LOW clarity — broad and neutral foundation"
+ if current_round == 2:
+ return "medium", "MEDIUM clarity — start introducing gentle differentiators"
+ return "high", "HIGH clarity — press with confident, specific traits"
+
+
+def _trim_text_for_prompt(text: str, limit: int = 180) -> str:
+ """Normalize whitespace and trim long passages for prompt friendliness."""
+ cleaned = " ".join(str(text).split())
+ if len(cleaned) <= limit:
+ return cleaned
+ return cleaned[: limit - 1].rstrip() + "…"
+
+
+def _format_speeches_xml(
+ completed_speeches: Sequence[Speech],
+ rounds_to_keep: int | None = None,
+ max_entries: int | None = None,
+) -> str:
+ """Render speeches in chronological order with optional trimming."""
+ if not completed_speeches:
+ return ""
+
+ ordered = sorted(
+ completed_speeches,
+ key=lambda s: (s.get("round", 0), s.get("seq", 0)),
+ )
+
+ if rounds_to_keep is not None:
+ round_ids = sorted({speech.get("round", 0) for speech in ordered})
+ selected_rounds = set(round_ids[-rounds_to_keep:])
+ filtered = [
+ speech for speech in ordered if speech.get("round", 0) in selected_rounds
+ ]
+ else:
+ filtered = ordered
+
+ if max_entries is not None and len(filtered) > max_entries:
+ filtered = filtered[-max_entries:]
+
+ segments = [""]
+ current_round = None
+
+ for speech in filtered:
+ round_index = speech.get("round", 0)
+ if round_index != current_round:
+ if current_round is not None:
+ segments.append("")
+ segments.append(f'')
+ current_round = round_index
+
+ segments.append(
+ (
+ f''
+ f"{escape(_trim_text_for_prompt(speech.get("content", ""), limit=140))}"
+ ""
+ )
+ )
+
+ if current_round is not None:
+ segments.append("")
+
+ segments.append("")
+ return "".join(segments)
+
+
+def _build_speech_user_context(
+ self_belief: SelfBelief,
+ completed_speeches: Sequence[Speech],
+ me: str,
+ alive: List[str],
+ current_round: int,
+) -> str:
+ """Builds the dynamic part of the speech prompt as structured XML."""
+ self_role = self_belief.role
+ self_confidence = self_belief.confidence
+
+ clarity_code, clarity_desc = _determine_clarity(
+ self_role, self_confidence, current_round
+ )
+
+ alive_tags = "".join(f'' for pid in alive)
+ alive_block = f"{alive_tags or ''}"
+
+ speech_logs_block = _format_speeches_xml(completed_speeches)
+
+ return (
+ ""
+ f''
+ f'{escape(clarity_desc)}'
+ f''
+ f'{alive_block}{speech_logs_block}'
+ "Return exactly one line of speech; avoid emojis, labels, or extra commentary."
+ ""
+ )
+
+
+_CIVILIAN_SPEECH_PROMPT_PREFIX = """You are a civilian player in the party game "Who is the Spy". Your secret word is "{my_word}" and it is your turn to speak.
+Goal: Share one truthful clue that helps fellow civilians test the group without giving away the exact word.
+Must:
+- Reply in the same language as "{my_word}".
+- Output exactly one line of plain text; no labels, emojis, quotes, or meta reasoning.
+- Tell the truth about your word; do not say the word itself or obvious synonyms.
+- Do not mention roles, probabilities, mechanics, questions, accusations, or player names.
+- Avoid repeating another player's description this round.
+- Stay concise: 18-35 characters for Chinese/Japanese/Korean, otherwise 20-40 words.
+Guide:
+- Follow the tag in the tag to match the desired clarity for this turn.
+- Use the confidence value in the tag to decide how bold to be: higher confidence supports sharper differentiators, lower confidence favors safer overlaps.
+- Choose 2-3 aspects such as category, purpose, setting, sensory detail, or user.
+- Mirror the tone and vocabulary other players use.
+- Skip brands, numbers, and rare trivia unless essential.
+Reply now with your single-line speech."""
+
+_SPY_SPEECH_PROMPT_PREFIX = """You are the spy in the party game "Who is the Spy". Your secret word is "{my_word}" and it is your turn to speak.
+Goal: Blend in by giving a plausible clue that could also fit the civilians' word while safely disguising your own.
+Must:
+- Reply in the same language as "{my_word}".
+- Output exactly one line of plain text; no labels, emojis, quotes, or meta reasoning.
+- Prioritize overlap with likely civilian clues; you may soften or generalize details to avoid exposing your unique angle.
+- Do not mention roles, probabilities, mechanics, questions, accusations, or player names.
+- Avoid repeating another player's description this round.
+- Stay concise: 18-35 characters for Chinese/Japanese/Korean, otherwise 20-40 words.
+Guide:
+- Follow the tag in the tag and mirror the group's clarity while masking differences.
+- If you sense conflict with the group, emphasize broad categories, shared settings, or emotions instead of specifics.
+- Choose 2-3 aspects such as category, purpose, setting, sensory detail, or user that civilians might also mention.
+- Mirror the tone and vocabulary other players use.
+- Avoid brands, numbers, and rare trivia unless essential.
+Reply now with your single-line speech."""
+
+
+_EMOJI_REGEX = re.compile("[\u2600-\u26ff\u2700-\u27bf\U0001f300-\U0001faff]")
+
+
+def _sanitize_speech_output(text: Any) -> str:
+ """Flatten whitespace, drop emojis, and enforce a single-line speech."""
+ if text is None:
+ return ""
+
+ raw = str(text)
+ lines = [
+ line.strip() for line in raw.replace("\r", "").splitlines() if line.strip()
+ ]
+ candidate = lines[-1] if lines else raw.strip()
+ candidate = candidate.replace("\n", " ")
+ candidate = _EMOJI_REGEX.sub("", candidate)
+ candidate = " ".join(candidate.split())
+ return candidate
+
+
+def _format_speech_system_prompt(my_word: str, self_belief: SelfBelief) -> str:
+ """Select the civilian or spy speech prompt based on calibrated confidence."""
+ is_confident_spy = self_belief.role == "spy" and self_belief.confidence >= 0.7
+ if is_confident_spy:
+ template = _SPY_SPEECH_PROMPT_PREFIX
+ else:
+ template = _CIVILIAN_SPEECH_PROMPT_PREFIX
+ return template.format(my_word=my_word)
+
+
+def generate_speech_tool(
+ state: GameState,
+ player_id: str,
+) -> Dict[str, Any]:
+ """
+ Generate strategic speech for a player.
+
+ This tool generates speech based on:
+ - Player's current mindset (self-belief and suspicions)
+ - Assigned word
+ - Current game phase and round
+ - Previous speeches
+
+ Access Control:
+ - Can only access player's own private state (player_private_states[player_id])
+ - Can access public game state (completed_speeches, current_round, etc.)
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player generating speech
+
+ Returns:
+ Dictionary with new speech added to completed_speeches
+ """
+
+ # Access control: validate player_id exists
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+
+ # Check if it's speaking phase
+ if state["game_phase"] != "speaking":
+ return {}
+
+ # Get player's private state (allowed)
+ player_private = state["player_private_states"][player_id]
+ my_word = player_private.assigned_word
+ player_mindset = player_private.playerMindset
+
+ # Get public game state (allowed)
+ completed_speeches = state.get("completed_speeches", [])
+ current_round = state["current_round"]
+
+ # Get alive players
+ eliminated = set(state.get("eliminated_players", []))
+ alive = [p for p in state["players"] if p not in eliminated]
+
+ # Build prompt
+ system_prompt = _format_speech_system_prompt(my_word, player_mindset.self_belief)
+ user_context = _build_speech_user_context(
+ player_mindset.self_belief, completed_speeches, player_id, alive, current_round
+ )
+
+ # Call LLM
+ from src.tools.llm import create_llm
+
+ llm_client = create_llm()
+
+ messages = [
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=user_context),
+ ]
+
+ response = llm_client.invoke(messages)
+ raw_text = response.content if hasattr(response, "content") else response
+ speech_text = _sanitize_speech_output(raw_text)
+
+ # Create speech record
+ speech_record = create_speech_record(state, player_id, speech_text)
+
+ # Return update dictionary
+ return {"completed_speeches": [speech_record]}
+
+
+def analyze_speech_consistency(
+ state: GameState,
+ player_id: str,
+ target_player_id: str,
+) -> Dict[str, Any]:
+ """
+ Analyze speech consistency of a target player.
+
+ This tool analyzes whether target's speeches align with group consensus
+ or show outlier patterns.
+
+ Access Control:
+ - Can only access public speeches
+ - Cannot access any private states
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player performing analysis
+ target_player_id: ID of the player to analyze
+
+ Returns:
+ Analysis results with consistency assessment
+ """
+
+ # Access control: validate both player IDs
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+ if target_player_id not in state["players"]:
+ raise ValueError(f"Invalid target_player_id: {target_player_id}")
+
+ # Get all speeches (public data)
+ completed_speeches = state.get("completed_speeches", [])
+
+ # Get target player's speeches
+ target_speeches = [
+ s for s in completed_speeches if s.get("player_id") == target_player_id
+ ]
+
+ # Get all other speeches for comparison
+ other_speeches = [
+ s for s in completed_speeches if s.get("player_id") != target_player_id
+ ]
+
+ # Simple analysis
+ analysis = {
+ "target_player_id": target_player_id,
+ "speech_count": len(target_speeches),
+ "target_speeches": [s.get("content", "") for s in target_speeches],
+ "other_speech_count": len(other_speeches),
+ # Placeholder metrics - could be enhanced
+ "avg_speech_length": sum(len(s.get("content", "")) for s in target_speeches)
+ / max(len(target_speeches), 1),
+ "is_consistent": True, # Placeholder
+ }
+
+ return analysis
diff --git a/src/game/agents/tools/voting.py b/src/game/agents/tools/voting.py
new file mode 100644
index 0000000..d8e94bd
--- /dev/null
+++ b/src/game/agents/tools/voting.py
@@ -0,0 +1,167 @@
+"""
+Vote decision support tools.
+
+This module provides tools for making voting decisions
+based on accumulated suspicions and game state.
+
+Access Control:
+- Players can only access their own private state
+- Players can access public game state
+"""
+
+from typing import Any, Dict
+from datetime import datetime
+
+from src.game.state import GameState, Vote, PlayerMindset
+
+
+def decide_vote_tool(
+ state: GameState,
+ player_id: str,
+) -> Dict[str, Any]:
+ """
+ Decide vote target based on player's suspicions and beliefs.
+
+ This tool makes voting decisions by:
+ - Analyzing player's self-belief and confidence
+ - Evaluating suspicions about other players
+ - Selecting the most likely spy target
+
+ Vote Decision Logic:
+ 1. Determine own role (use opposite if confidence < 50%)
+ 2. Calculate scores for other players based on suspicions
+ 3. Vote for player with the highest suspicion score
+
+ Access Control:
+ - Can only access player's own private state (player_private_states[player_id])
+ - Can access public game state (alive players, etc.)
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player making the vote decision
+
+ Returns:
+ Dictionary with new vote added to current_votes
+ """
+
+ # Access control: validate player_id exists
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+
+ # Check if it's voting phase
+ if state["game_phase"] != "voting":
+ return {}
+
+ # Get player's private state (allowed)
+ player_private = state["player_private_states"][player_id]
+ player_mindset = player_private.playerMindset
+
+ # Get alive players from public state
+ eliminated = set(state.get("eliminated_players", []))
+ alive = [p for p in state["players"] if p not in eliminated]
+
+ # Ensure player is alive
+ if player_id not in alive:
+ return {}
+
+ # Determine own role: if confidence > 50%, use current role, otherwise use opposite
+ my_self_belief = player_mindset.self_belief
+ my_role = my_self_belief.role
+ if my_self_belief.confidence < 0.5:
+ # Use opposite role (uncertain about self)
+ my_role = "spy" if my_role == "civilian" else "civilian"
+
+ # Calculate scores for other players based on suspicions
+ player_scores: Dict[str, float] = {}
+ for other_player_id in alive:
+ if other_player_id == player_id:
+ continue
+
+ score = 0.0
+ suspicions = player_mindset.suspicions or {}
+ if other_player_id in suspicions:
+ suspicion = suspicions[other_player_id]
+ if my_role == suspicion.role:
+ # Positive score means we trust them (same role alignment)
+ score = suspicion.confidence
+ else:
+ # Negative score means we distrust them (different role alignment)
+ score = -suspicion.confidence
+ player_scores[other_player_id] = score
+
+ # Select vote target
+ if player_scores:
+ # Pick the lowest score (most distrust) to target suspected opponents
+ voted_target = min(player_scores, key=player_scores.get)
+ else:
+ # Fallback if no suspicions
+ other_alive = [p for p in alive if p != player_id]
+ if other_alive:
+ voted_target = other_alive[0] # Vote for the first other alive player
+ elif alive: # Only self is alive
+ voted_target = player_id
+ else: # Should not happen in a valid game state
+ raise ValueError("No alive players to vote for.")
+
+ # Create vote record
+ ts = int(datetime.now().timestamp() * 1000)
+ new_vote = Vote(target=voted_target, ts=ts, phase_id=state["phase_id"])
+
+ # Return update dictionary
+ return {"current_votes": {player_id: new_vote}}
+
+
+def analyze_voting_patterns(
+ state: GameState,
+ player_id: str,
+) -> Dict[str, Any]:
+ """
+ Analyze voting patterns across the game.
+
+ This tool analyzes:
+ - Historical voting behavior
+ - Vote targeting patterns
+ - Bandwagon detection
+
+ Access Control:
+ - Can only access public voting data
+ - Cannot access private states
+
+ Args:
+ state: Current GameState
+ player_id: ID of the player performing analysis
+
+ Returns:
+ Analysis results with voting pattern insights
+ """
+
+ # Access control: validate player_id
+ if player_id not in state.get("player_private_states", {}):
+ raise ValueError(f"Invalid player_id: {player_id}")
+
+ # Get current votes (public data)
+ current_votes = state.get("current_votes", {})
+
+ # Analyze vote distribution
+ vote_counts: Dict[str, int] = {}
+ for voter_id, vote in current_votes.items():
+ target = vote.target
+ vote_counts[target] = vote_counts.get(target, 0) + 1
+
+ # Find most voted player
+ most_voted = None
+ max_votes = 0
+ if vote_counts:
+ most_voted = max(vote_counts, key=vote_counts.get)
+ max_votes = vote_counts[most_voted]
+
+ analysis = {
+ "total_votes": len(current_votes),
+ "vote_distribution": vote_counts,
+ "most_voted_player": most_voted,
+ "most_voted_count": max_votes,
+ "is_bandwagon": max_votes
+ > len(current_votes) / 2, # Simple bandwagon detection
+ }
+
+ return analysis
diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/agents/tools/__init__.py b/tests/agents/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/agents/tools/test_identity.py b/tests/agents/tools/test_identity.py
new file mode 100644
index 0000000..1011658
--- /dev/null
+++ b/tests/agents/tools/test_identity.py
@@ -0,0 +1,171 @@
+"""
+Unit tests for identity inference tools.
+
+Tests cover:
+- update_player_mindset_tool functionality
+- Access control validation
+- State updates and merging
+- Error handling
+"""
+
+import pytest
+from unittest.mock import Mock, patch, MagicMock
+from src.game.agents.tools.identity import (
+ update_player_mindset_tool,
+ analyze_speech_consistency,
+)
+from src.game.state import (
+ GameState,
+ PlayerPrivateState,
+ PlayerMindset,
+ SelfBelief,
+ Suspicion,
+ Speech,
+)
+
+
+@pytest.fixture
+def mock_game_state():
+ """Create a mock game state for testing."""
+ return {
+ "game_id": "test-game-1",
+ "players": ["player1", "player2", "player3"],
+ "current_round": 1,
+ "game_phase": "speaking",
+ "phase_id": "1:speaking:abc123",
+ "completed_speeches": [
+ {
+ "round": 1,
+ "seq": 0,
+ "player_id": "player2",
+ "content": "It's something you can eat",
+ "ts": 1234567890,
+ }
+ ],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {
+ "player1": "spy",
+ "player2": "civilian",
+ "player3": "civilian",
+ },
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "player1": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.5),
+ suspicions={},
+ ),
+ ),
+ "player2": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.7),
+ suspicions={},
+ ),
+ ),
+ "player3": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.6),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+
+@pytest.fixture
+def mock_runtime(mock_game_state):
+ """Create a mock ToolRuntime."""
+ # Not needed anymore since we pass state directly
+ return mock_game_state
+
+
+def test_update_player_mindset_tool_access_control(mock_runtime):
+ """Test that invalid player_id raises ValueError."""
+ with pytest.raises(ValueError, match="Invalid player_id"):
+ update_player_mindset_tool(mock_runtime, "invalid_player")
+
+
+@patch("src.tools.llm.create_llm")
+@patch("src.game.agents.tools.identity.create_extractor")
+def test_update_player_mindset_tool_success(mock_extractor, mock_llm):
+ """Test successful mindset update."""
+ # Create mock game state
+ mock_game_state = {
+ "game_id": "test-game-1",
+ "players": ["player1", "player2"],
+ "current_round": 1,
+ "game_phase": "speaking",
+ "phase_id": "1:speaking:abc123",
+ "completed_speeches": [],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {"player1": "spy", "player2": "civilian"},
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "player1": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.5),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+ # Mock LLM response
+ new_mindset = PlayerMindset(
+ self_belief=SelfBelief(role="spy", confidence=0.8),
+ suspicions={
+ "player2": Suspicion(
+ role="civilian", confidence=0.75, reason="Consistent with group"
+ )
+ },
+ )
+
+ mock_extractor_instance = Mock()
+ mock_extractor_instance.invoke.return_value = {"responses": [new_mindset]}
+ mock_extractor.return_value = mock_extractor_instance
+
+ # Call tool
+ result = update_player_mindset_tool(mock_game_state, "player1")
+
+ # Verify result
+ assert result is not None
+ assert "player_private_states" in result
+ assert "player1" in result["player_private_states"]
+
+ updated_state = result["player_private_states"]["player1"]
+ assert updated_state.playerMindset.self_belief.role == "spy"
+ assert updated_state.playerMindset.self_belief.confidence == 0.8
+
+
+def test_analyze_speech_consistency_access_control(mock_runtime):
+ """Test access control for speech analysis."""
+ with pytest.raises(ValueError, match="Invalid player_id"):
+ analyze_speech_consistency(mock_runtime, "invalid_player", "player2")
+
+ with pytest.raises(ValueError, match="Invalid target_player_id"):
+ analyze_speech_consistency(mock_runtime, "player1", "invalid_target")
+
+
+def test_analyze_speech_consistency_success(mock_runtime):
+ """Test successful speech consistency analysis."""
+ result = analyze_speech_consistency(mock_runtime, "player1", "player2")
+
+ assert result is not None
+ assert result["target_player_id"] == "player2"
+ assert result["speech_count"] == 1
+ assert len(result["speeches"]) == 1
+ assert result["speeches"][0] == "It's something you can eat"
diff --git a/tests/agents/tools/test_speech.py b/tests/agents/tools/test_speech.py
new file mode 100644
index 0000000..fedf730
--- /dev/null
+++ b/tests/agents/tools/test_speech.py
@@ -0,0 +1,215 @@
+"""
+Unit tests for speech generation tools.
+
+Tests cover:
+- generate_speech_tool functionality
+- Access control validation
+- Speech sanitization
+- Clarity determination
+"""
+
+import pytest
+from unittest.mock import Mock, patch
+from src.game.agents.tools.speech import (
+ generate_speech_tool,
+ analyze_speech_consistency,
+ _determine_clarity,
+ _sanitize_speech_output,
+)
+from src.game.state import (
+ PlayerPrivateState,
+ PlayerMindset,
+ SelfBelief,
+)
+
+
+@pytest.fixture
+def mock_game_state():
+ """Create a mock game state for testing."""
+ return {
+ "game_id": "test-game-1",
+ "players": ["player1", "player2", "player3"],
+ "current_round": 2,
+ "game_phase": "speaking",
+ "phase_id": "2:speaking:xyz789",
+ "completed_speeches": [],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {
+ "player1": "spy",
+ "player2": "civilian",
+ "player3": "civilian",
+ },
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "player1": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="spy", confidence=0.8),
+ suspicions={},
+ ),
+ ),
+ "player2": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.7),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+
+@pytest.fixture
+def mock_runtime(mock_game_state):
+ """Create a mock ToolRuntime."""
+ # Not needed anymore since we pass state directly
+ return mock_game_state
+
+
+def test_generate_speech_tool_access_control(mock_runtime):
+ """Test that invalid player_id raises ValueError."""
+ with pytest.raises(ValueError, match="Invalid player_id"):
+ generate_speech_tool(mock_runtime, "invalid_player")
+
+
+def test_generate_speech_tool_wrong_phase(mock_runtime):
+ """Test that tool returns empty update when not in speaking phase."""
+ mock_runtime["game_phase"] = "voting"
+
+ result = generate_speech_tool(mock_runtime, "player1")
+
+ assert result is not None
+ assert result == {}
+
+
+@patch("src.tools.llm.create_llm")
+def test_generate_speech_tool_success(mock_llm):
+ """Test successful speech generation."""
+ # Create mock game state
+ mock_game_state = {
+ "game_id": "test-game-1",
+ "players": ["player1", "player2"],
+ "current_round": 2,
+ "game_phase": "speaking",
+ "phase_id": "2:speaking:xyz789",
+ "completed_speeches": [],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {"player1": "spy", "player2": "civilian"},
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "player1": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="spy", confidence=0.8),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+ # Mock LLM response
+ mock_response = Mock()
+ mock_response.content = "It's yellow and sweet"
+ mock_llm_instance = Mock()
+ mock_llm_instance.invoke.return_value = mock_response
+ mock_llm.return_value = mock_llm_instance
+
+ result = generate_speech_tool(mock_game_state, "player1")
+
+ assert result is not None
+ assert "completed_speeches" in result
+ assert len(result["completed_speeches"]) == 1
+
+ speech = result["completed_speeches"][0]
+ assert speech["player_id"] == "player1"
+ assert speech["content"] == "It's yellow and sweet"
+ assert speech["round"] == 2
+
+
+def test_determine_clarity_spy():
+ """Test clarity determination for spy role."""
+ # Early rounds - low clarity
+ clarity, desc = _determine_clarity("spy", 0.8, 1)
+ assert clarity == "low"
+ assert "LOW clarity" in desc
+
+ # Mid rounds - medium clarity
+ clarity, desc = _determine_clarity("spy", 0.8, 3)
+ assert clarity == "medium"
+ assert "MEDIUM clarity" in desc
+
+ # Late rounds - still medium
+ clarity, desc = _determine_clarity("spy", 0.8, 5)
+ assert clarity == "medium"
+ assert "MEDIUM clarity" in desc
+
+
+def test_determine_clarity_civilian():
+ """Test clarity determination for civilian role."""
+ # Round 1 - low clarity
+ clarity, desc = _determine_clarity("civilian", 0.7, 1)
+ assert clarity == "low"
+ assert "LOW clarity" in desc
+
+ # Round 2 - medium clarity
+ clarity, desc = _determine_clarity("civilian", 0.7, 2)
+ assert clarity == "medium"
+ assert "MEDIUM clarity" in desc
+
+ # Round 3+ - high clarity
+ clarity, desc = _determine_clarity("civilian", 0.7, 3)
+ assert clarity == "high"
+ assert "HIGH clarity" in desc
+
+
+def test_sanitize_speech_output():
+ """Test speech output sanitization."""
+ # Test emoji removal
+ assert _sanitize_speech_output("Hello 😊 world") == "Hello world"
+
+ # Test whitespace normalization
+ assert _sanitize_speech_output("Hello world\n\n") == "Hello world"
+
+ # Test multi-line to single line
+ assert _sanitize_speech_output("Line 1\nLine 2\nLine 3") == "Line 3"
+
+ # Test None input
+ assert _sanitize_speech_output(None) == ""
+
+
+def test_analyze_speech_consistency_tool(mock_runtime):
+ """Test speech consistency analysis."""
+ # Add some speeches
+ mock_runtime["completed_speeches"] = [
+ {
+ "round": 1,
+ "seq": 0,
+ "player_id": "player2",
+ "content": "It's red and sweet",
+ "ts": 1234567890,
+ },
+ {
+ "round": 1,
+ "seq": 1,
+ "player_id": "player2",
+ "content": "It's a fruit",
+ "ts": 1234567891,
+ },
+ ]
+
+ result = analyze_speech_consistency(mock_runtime, "player1", "player2")
+
+ assert result is not None
+ assert result["target_player_id"] == "player2"
+ assert result["speech_count"] == 2
+ assert len(result["target_speeches"]) == 2
diff --git a/tests/agents/tools/test_voting.py b/tests/agents/tools/test_voting.py
new file mode 100644
index 0000000..ef7d676
--- /dev/null
+++ b/tests/agents/tools/test_voting.py
@@ -0,0 +1,192 @@
+"""
+Unit tests for voting decision tools.
+
+Tests cover:
+- decide_vote_tool functionality
+- Vote decision logic
+- Access control validation
+- Voting pattern analysis
+"""
+
+import pytest
+from unittest.mock import Mock
+from src.game.agents.tools.voting import (
+ decide_vote_tool,
+ analyze_voting_patterns,
+)
+from src.game.state import (
+ PlayerPrivateState,
+ PlayerMindset,
+ SelfBelief,
+ Suspicion,
+ Vote,
+)
+
+
+@pytest.fixture
+def mock_game_state():
+ """Create a mock game state for testing."""
+ return {
+ "game_id": "test-game-1",
+ "players": ["player1", "player2", "player3"],
+ "current_round": 2,
+ "game_phase": "voting",
+ "phase_id": "2:voting:xyz789",
+ "completed_speeches": [],
+ "eliminated_players": [],
+ "current_votes": {},
+ "winner": None,
+ "host_private_state": {
+ "player_roles": {
+ "player1": "spy",
+ "player2": "civilian",
+ "player3": "civilian",
+ },
+ "civilian_word": "apple",
+ "spy_word": "banana",
+ },
+ "player_private_states": {
+ "player1": PlayerPrivateState(
+ assigned_word="banana",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="spy", confidence=0.8),
+ suspicions={
+ "player2": Suspicion(
+ role="civilian", confidence=0.75, reason="Consistent"
+ ),
+ "player3": Suspicion(
+ role="spy", confidence=0.6, reason="Vague speech"
+ ),
+ },
+ ),
+ ),
+ "player2": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.7),
+ suspicions={
+ "player1": Suspicion(
+ role="spy", confidence=0.8, reason="Outlier"
+ ),
+ },
+ ),
+ ),
+ "player3": PlayerPrivateState(
+ assigned_word="apple",
+ playerMindset=PlayerMindset(
+ self_belief=SelfBelief(role="civilian", confidence=0.6),
+ suspicions={},
+ ),
+ ),
+ },
+ }
+
+
+@pytest.fixture
+def mock_runtime(mock_game_state):
+ """Create a mock ToolRuntime."""
+ # Not needed anymore since we pass state directly
+ return mock_game_state
+
+
+def test_decide_vote_tool_access_control(mock_runtime):
+ """Test that invalid player_id raises ValueError."""
+ with pytest.raises(ValueError, match="Invalid player_id"):
+ decide_vote_tool(mock_runtime, "invalid_player")
+
+
+def test_decide_vote_tool_wrong_phase(mock_runtime):
+ """Test that tool returns empty update when not in voting phase."""
+ mock_runtime["game_phase"] = "speaking"
+
+ result = decide_vote_tool(mock_runtime, "player1")
+
+ assert result is not None
+ assert result == {}
+
+
+def test_decide_vote_tool_success(mock_runtime):
+ """Test successful vote decision."""
+ result = decide_vote_tool(mock_runtime, "player1")
+
+ assert result is not None
+ assert "current_votes" in result
+ assert "player1" in result["current_votes"]
+
+ vote = result["current_votes"]["player1"]
+ assert isinstance(vote, Vote)
+ # Player1 is spy with high confidence, so should vote for someone they suspect is spy
+ # or someone they don't trust (different role alignment)
+ assert vote.target in ["player2", "player3"]
+ assert vote.phase_id == "2:voting:xyz789"
+
+
+def test_decide_vote_tool_low_confidence(mock_runtime):
+ """Test vote decision with low confidence (role reversal)."""
+ # Set player1's confidence below 0.5
+ mock_runtime["player_private_states"][
+ "player1"
+ ].playerMindset.self_belief.confidence = 0.4
+
+ result = decide_vote_tool(mock_runtime, "player1")
+
+ assert result is not None
+ assert "current_votes" in result
+
+ vote = result["current_votes"]["player1"]
+ assert vote.target in ["player2", "player3"]
+
+
+def test_decide_vote_tool_no_suspicions(mock_runtime):
+ """Test vote decision when player has no suspicions."""
+ # Clear suspicions for player3
+ result = decide_vote_tool(mock_runtime, "player3")
+
+ assert result is not None
+ assert "current_votes" in result
+
+ vote = result["current_votes"]["player3"]
+ # Should vote for first available other player
+ assert vote.target in ["player1", "player2"]
+
+
+def test_decide_vote_tool_eliminated_player(mock_runtime):
+ """Test that eliminated player cannot vote."""
+ mock_runtime["eliminated_players"] = ["player1"]
+
+ result = decide_vote_tool(mock_runtime, "player1")
+
+ assert result is not None
+ assert result == {}
+
+
+def test_analyze_voting_patterns(mock_runtime):
+ """Test voting pattern analysis."""
+ # Add some votes
+ mock_runtime["current_votes"] = {
+ "player1": Vote(target="player2", ts=1234567890, phase_id="2:voting:xyz789"),
+ "player2": Vote(target="player1", ts=1234567891, phase_id="2:voting:xyz789"),
+ "player3": Vote(target="player2", ts=1234567892, phase_id="2:voting:xyz789"),
+ }
+
+ result = analyze_voting_patterns(mock_runtime, "player1")
+
+ assert result is not None
+ assert result["total_votes"] == 3
+ assert result["vote_distribution"]["player2"] == 2
+ assert result["vote_distribution"]["player1"] == 1
+ assert result["most_voted_player"] == "player2"
+ assert result["most_voted_count"] == 2
+ assert result["is_bandwagon"] is True # 2 > 3/2
+
+
+def test_analyze_voting_patterns_no_votes(mock_runtime):
+ """Test voting pattern analysis with no votes."""
+ result = analyze_voting_patterns(mock_runtime, "player1")
+
+ assert result is not None
+ assert result["total_votes"] == 0
+ assert result["vote_distribution"] == {}
+ assert result["most_voted_player"] is None
+ assert result["most_voted_count"] == 0
+ assert result["is_bandwagon"] is False