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