Spaces:
Configuration error
Configuration error
| """ | |
| Agent Tools - Helper functions for LLM AI Agents | |
| This module provides tools that the LLM can use to help make better decisions. | |
| These tools are designed to PREVENT HALLUCINATIONS by providing processed information | |
| rather than forcing the LLM to interpret raw data structures. | |
| Tools available: | |
| 1. inspect_node - Get detailed info about a specific node (prevents hallucinations) | |
| 2. find_best_nodes - Search for nodes by criteria (prevents missing opportunities) | |
| 3. analyze_path_potential - Analyze road building potential (helps with strategy) | |
| """ | |
| from typing import Dict, Any, List, Optional, Set, Tuple | |
| # Pip values (dots on tiles): represents probability of each dice number | |
| PIP_VALUES = { | |
| 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, | |
| 8: 5, 9: 4, 10: 3, 11: 2, 12: 1 | |
| } | |
| class AgentTools: | |
| """ | |
| Collection of helper tools for AI agents. | |
| These tools are designed to PREVENT HALLUCINATIONS by providing | |
| the LLM with processed, accurate information rather than forcing | |
| it to interpret complex data structures (Arrays N and H). | |
| Key principle: The LLM asks questions, the tools provide answers. | |
| """ | |
| def __init__(self, game_state: Optional[Dict[str, Any]] = None): | |
| """ | |
| Initialize agent tools with game state. | |
| Args: | |
| game_state: Current game state dictionary (from state_optimizer) | |
| """ | |
| self.game_state = game_state or {} | |
| self._update_internal_structures() | |
| def update_game_state(self, game_state: Dict[str, Any]): | |
| """ | |
| Update the game state (called each turn). | |
| Args: | |
| game_state: New game state dictionary | |
| """ | |
| self.game_state = game_state | |
| self._update_internal_structures() | |
| def _update_internal_structures(self): | |
| """ | |
| Build internal lookup structures from game state for fast queries. | |
| Supports the COMPACT format from state_optimizer: | |
| - H: Array of hex data. H[id] = "W12" (Wood, number 12) | |
| - N: Array of node data. N[id] = [[neighbors], [hex_ids], port?] | |
| - state: {"bld": [[node, owner, type], ...], "rds": [[[from,to], owner], ...]} | |
| Resource codes: W=Wood, B=Brick, S=Sheep, Wh=Wheat, O=Ore, D=Desert | |
| Port codes: ?3=Any 3:1, W2=Wood 2:1, etc. | |
| """ | |
| # Resource code mapping (compact -> full name) | |
| RES_DECODE = {"W": "Wood", "B": "Brick", "S": "Sheep", "Wh": "Wheat", "O": "Ore", "D": "Desert"} | |
| # Extract compact arrays | |
| self.H = self.game_state.get("H", []) # Hex array | |
| self.N = self.game_state.get("N", []) # Node array | |
| self.state = self.game_state.get("state", {}) # Buildings & roads | |
| # Build hex lookup: hex_id -> {type, number} | |
| self.tile_lookup: Dict[int, Dict[str, Any]] = {} | |
| for hex_id, hex_val in enumerate(self.H): | |
| if hex_val: # Skip empty entries | |
| # Parse "W12" -> type="Wood", number=12 | |
| # Or "D" -> type="Desert", number=0 | |
| resource_code = "" | |
| number = 0 | |
| # Handle "Wh" (2 char) vs "W" (1 char) | |
| if hex_val.startswith("Wh"): | |
| resource_code = "Wh" | |
| num_str = hex_val[2:] | |
| elif len(hex_val) >= 1: | |
| resource_code = hex_val[0] | |
| num_str = hex_val[1:] | |
| if num_str.isdigit(): | |
| number = int(num_str) | |
| self.tile_lookup[hex_id] = { | |
| "type": RES_DECODE.get(resource_code, resource_code), | |
| "number": number | |
| } | |
| # Build buildings lookup from state.bld: [[node, owner, type], ...] | |
| self.buildings: Dict[int, Dict[str, Any]] = {} # node_id -> building info | |
| for bld in self.state.get("bld", []): | |
| if len(bld) >= 3: | |
| node_id, owner, bld_type = bld[0], bld[1], bld[2] | |
| self.buildings[node_id] = { | |
| "owner": owner, | |
| "type": "settlement" if bld_type == "S" else "city" | |
| } | |
| # Build node lookup: node_id -> node_data (converted from compact format) | |
| self.node_lookup: Dict[int, Dict[str, Any]] = {} | |
| for node_id, node_val in enumerate(self.N): | |
| if node_val is not None: # Skip null entries (index 0 is often null) | |
| # N[id] = [[neighbors], [hex_ids], port?] | |
| neighbors = node_val[0] if len(node_val) > 0 else [] | |
| hex_ids = node_val[1] if len(node_val) > 1 else [] | |
| port = node_val[2] if len(node_val) > 2 else None | |
| # Check if this node has a building | |
| building = self.buildings.get(node_id) | |
| self.node_lookup[node_id] = { | |
| "id": node_id, | |
| "neighbors": neighbors, | |
| "adjacent_tiles": hex_ids, | |
| "port": port, | |
| "building": building | |
| } | |
| # For backwards compatibility | |
| self.nodes = list(self.node_lookup.values()) | |
| self.tiles = list(self.tile_lookup.values()) | |
| # ========== TOOL 1: Inspect Node (Prevents Hallucinations) ========== | |
| def inspect_node(self, node_id: int, reasoning: str = "") -> Dict[str, Any]: | |
| """ | |
| Get detailed, processed information about a specific node. | |
| CRITICAL: This prevents hallucinations! | |
| Instead of the LLM trying to interpret Arrays N and H, it asks this tool | |
| for accurate information about a specific node. | |
| Args: | |
| node_id: The node ID to inspect (e.g., 10, 18, 40) | |
| reasoning: LLM's explanation for why it's inspecting this node | |
| Returns: | |
| Dictionary with complete node information | |
| """ | |
| # Check if node exists | |
| if node_id not in self.node_lookup: | |
| return { | |
| "node_id": node_id, | |
| "exists": False, | |
| "error": f"Node {node_id} does not exist on the board", | |
| "llm_reasoning": reasoning | |
| } | |
| node = self.node_lookup[node_id] | |
| # Extract resources and calculate pips | |
| resources = {} | |
| resources_detailed = [] # List of all resources with their numbers | |
| total_pips = 0 | |
| adjacent_tiles = node.get("adjacent_tiles", []) | |
| for tile_id in adjacent_tiles: | |
| if tile_id in self.tile_lookup: | |
| tile = self.tile_lookup[tile_id] | |
| resource = tile.get("type", "") | |
| number = tile.get("number", 0) | |
| # Skip desert | |
| if resource.lower() != "desert" and number > 0: | |
| # Add to detailed list | |
| resources_detailed.append({ | |
| "type": resource, | |
| "number": number, | |
| "pips": PIP_VALUES.get(number, 0) | |
| }) | |
| # Keep aggregated format for backwards compatibility | |
| if resource not in resources: | |
| resources[resource] = [] | |
| resources[resource].append(number) | |
| total_pips += PIP_VALUES.get(number, 0) | |
| # Check for port | |
| port = node.get("port") | |
| if port: | |
| # Normalize port format | |
| if isinstance(port, dict): | |
| port = port.get("type", None) | |
| # Check if occupied | |
| building = node.get("building") | |
| occupied = building is not None | |
| occupied_by = None | |
| building_type = None | |
| if building: | |
| occupied_by = building.get("owner", "Unknown") | |
| building_type = building.get("type", "settlement") | |
| # Get neighbors | |
| neighbors = node.get("neighbors", []) | |
| # Check if can build here | |
| can_build_here = not occupied | |
| blocked_reason = None | |
| if occupied: | |
| blocked_reason = f"Occupied by {occupied_by}'s {building_type}" | |
| else: | |
| # Check distance rule (no buildings within 1 edge) | |
| for neighbor_id in neighbors: | |
| if neighbor_id in self.node_lookup: | |
| neighbor_building = self.node_lookup[neighbor_id].get("building") | |
| if neighbor_building: | |
| can_build_here = False | |
| neighbor_owner = neighbor_building.get("owner", "someone") | |
| blocked_reason = f"Too close to {neighbor_owner}'s building at node {neighbor_id}" | |
| break | |
| return { | |
| "node_id": node_id, | |
| "exists": True, | |
| "resources": resources, # {"Wheat": [9, 6, 9], "Ore": [5]} | |
| "resources_detailed": resources_detailed, # [{"type": "Wheat", "number": 9, "pips": 4}, ...] | |
| "total_pips": total_pips, | |
| "port": port, | |
| "neighbors": neighbors, | |
| "occupied": occupied, | |
| "occupied_by": occupied_by, | |
| "building_type": building_type, | |
| "can_build_here": can_build_here, | |
| "blocked_reason": blocked_reason, | |
| "llm_reasoning": reasoning | |
| } | |
| # ========== TOOL 2: Find Best Nodes (Prevents Missing Opportunities) ========== | |
| def find_best_nodes( | |
| self, | |
| reasoning: str = "", | |
| min_pips: int = 0, | |
| must_have_resource: Optional[str] = None, | |
| exclude_blocked: bool = True, | |
| prefer_port: bool = False, | |
| limit: int = 10 | |
| ) -> Dict[str, Any]: | |
| """ | |
| Search for the best nodes on the board based on criteria. | |
| CRITICAL: This prevents missing opportunities! | |
| Instead of the LLM trying to visually scan Arrays N and H, it asks this | |
| tool to find the best positions that match specific criteria. | |
| Args: | |
| reasoning: LLM's explanation for this search strategy | |
| min_pips: Minimum total pip value (e.g., 10 for high-probability spots) | |
| must_have_resource: Required resource type (e.g., "Wheat", "Ore") | |
| exclude_blocked: Skip nodes that can't be built on | |
| prefer_port: Sort port nodes higher | |
| limit: Maximum number of results to return | |
| Returns: | |
| Dictionary with search results | |
| """ | |
| matching_nodes = [] | |
| # Search all nodes | |
| for node_id, node in self.node_lookup.items(): | |
| # Get node info using inspect_node | |
| info = self.inspect_node(node_id) | |
| if not info.get("exists"): | |
| continue | |
| # Apply filters | |
| if exclude_blocked and not info.get("can_build_here"): | |
| continue | |
| total_pips = info.get("total_pips", 0) | |
| if total_pips < min_pips: | |
| continue | |
| resources = info.get("resources", {}) | |
| if must_have_resource: | |
| # Case-insensitive resource check | |
| has_resource = False | |
| for res in resources.keys(): | |
| if res.lower() == must_have_resource.lower(): | |
| has_resource = True | |
| break | |
| if not has_resource: | |
| continue | |
| # Calculate score | |
| score = total_pips | |
| # Resource diversity bonus | |
| score += len(resources) * 0.5 | |
| # Port bonus | |
| if info.get("port"): | |
| score += 2.0 | |
| if info.get("port") != "3:1": | |
| score += 0.5 # Specialized port | |
| matching_nodes.append({ | |
| "node_id": node_id, | |
| "resources": resources, # {"Wheat": [9, 6, 9], "Ore": [5]} | |
| "resources_detailed": info.get("resources_detailed", []), # Full details | |
| "total_pips": total_pips, | |
| "port": info.get("port"), | |
| "neighbors": info.get("neighbors", []), | |
| "score": round(score, 1), | |
| "can_build": info.get("can_build_here", False), | |
| "occupied": info.get("occupied", False) | |
| }) | |
| # Sort by score (and prefer ports if requested) | |
| if prefer_port: | |
| matching_nodes.sort( | |
| key=lambda n: (n["port"] is not None, n["score"]), | |
| reverse=True | |
| ) | |
| else: | |
| matching_nodes.sort(key=lambda n: n["score"], reverse=True) | |
| # Limit results | |
| total_found = len(matching_nodes) | |
| matching_nodes = matching_nodes[:limit] | |
| return { | |
| "llm_reasoning": reasoning, | |
| "query": { | |
| "min_pips": min_pips, | |
| "must_have_resource": must_have_resource, | |
| "exclude_blocked": exclude_blocked, | |
| "prefer_port": prefer_port | |
| }, | |
| "total_found": total_found, | |
| "nodes": matching_nodes | |
| } | |
| # ========== TOOL 3: Analyze Path Potential (Helps with Road Strategy) ========== | |
| def analyze_path_potential( | |
| self, | |
| from_node: int, | |
| reasoning: str = "", | |
| direction_node: Optional[int] = None, | |
| max_depth: int = 2 | |
| ) -> Dict[str, Any]: | |
| """ | |
| Analyze the potential of building roads in a direction. | |
| CRITICAL: This helps with road-building strategy! | |
| Instead of the LLM guessing where roads lead, this tool shows exactly | |
| what opportunities exist 1-2 steps ahead (ports, high-value nodes, etc.) | |
| Args: | |
| from_node: Starting node ID | |
| reasoning: LLM's explanation for analyzing this path | |
| direction_node: Target direction (neighbor node ID), or None to check all directions | |
| max_depth: How many steps ahead to look (1 or 2) | |
| Returns: | |
| Dictionary with path analysis | |
| """ | |
| # Get starting node info | |
| from_info = self.inspect_node(from_node, reasoning="Internal call for path analysis") | |
| if not from_info.get("exists"): | |
| return { | |
| "error": f"Starting node {from_node} does not exist", | |
| "from_node": from_node, | |
| "llm_reasoning": reasoning | |
| } | |
| neighbors = from_info.get("neighbors", []) | |
| # If specific direction given, only analyze that one | |
| if direction_node is not None: | |
| if direction_node not in neighbors: | |
| return { | |
| "error": f"Node {direction_node} is not a neighbor of {from_node}", | |
| "from_node": from_node, | |
| "neighbors": neighbors, | |
| "llm_reasoning": reasoning | |
| } | |
| neighbors = [direction_node] | |
| paths = [] | |
| for neighbor_id in neighbors: | |
| path_analysis = { | |
| "direction": neighbor_id, | |
| "depth_1": None, | |
| "depth_2": None, | |
| "highlights": [], | |
| "score": 0.0 | |
| } | |
| # Depth 1: Immediate neighbor | |
| depth_1_info = self.inspect_node(neighbor_id, reasoning="Internal call for path analysis depth 1") | |
| if depth_1_info.get("exists"): | |
| path_analysis["depth_1"] = { | |
| "node_id": neighbor_id, | |
| "resources": depth_1_info.get("resources", {}), | |
| "total_pips": depth_1_info.get("total_pips", 0), | |
| "port": depth_1_info.get("port"), | |
| "can_build": depth_1_info.get("can_build_here", False), | |
| "occupied": depth_1_info.get("occupied", False) | |
| } | |
| # Score based on depth 1 | |
| score = depth_1_info.get("total_pips", 0) * 1.0 # Full weight | |
| # Highlights | |
| if depth_1_info.get("port"): | |
| path_analysis["highlights"].append( | |
| f"Port ({depth_1_info['port']}) at depth 1" | |
| ) | |
| score += 3.0 | |
| if depth_1_info.get("total_pips", 0) >= 12: | |
| path_analysis["highlights"].append("High-value node at depth 1") | |
| if depth_1_info.get("can_build_here"): | |
| path_analysis["highlights"].append("Can build settlement at depth 1") | |
| score += 1.0 | |
| # Depth 2: Look ahead one more step | |
| if max_depth >= 2: | |
| depth_2_neighbors = depth_1_info.get("neighbors", []) | |
| reachable_nodes = [] | |
| best_node = None | |
| best_pips = 0 | |
| for depth_2_id in depth_2_neighbors: | |
| if depth_2_id == from_node: # Don't go back | |
| continue | |
| depth_2_info = self.inspect_node(depth_2_id, reasoning="Internal call for path analysis depth 2") | |
| if depth_2_info.get("exists"): | |
| node_data = { | |
| "node_id": depth_2_id, | |
| "total_pips": depth_2_info.get("total_pips", 0), | |
| "port": depth_2_info.get("port"), | |
| "can_build": depth_2_info.get("can_build_here", False) | |
| } | |
| reachable_nodes.append(node_data) | |
| # Track best | |
| pips = depth_2_info.get("total_pips", 0) | |
| if pips > best_pips: | |
| best_pips = pips | |
| best_node = depth_2_id | |
| # Highlights at depth 2 | |
| if depth_2_info.get("port"): | |
| path_analysis["highlights"].append( | |
| f"Port ({depth_2_info['port']}) at depth 2 (node {depth_2_id})" | |
| ) | |
| score += 1.5 | |
| if pips >= 12: | |
| path_analysis["highlights"].append( | |
| f"High-value node at depth 2 (node {depth_2_id}, {pips} pips)" | |
| ) | |
| path_analysis["depth_2"] = { | |
| "reachable_nodes": reachable_nodes, | |
| "best_node": best_node, | |
| "best_pips": best_pips | |
| } | |
| # Add partial score for depth 2 (50% weight) | |
| score += best_pips * 0.5 | |
| path_analysis["score"] = round(score, 1) | |
| paths.append(path_analysis) | |
| # Sort by score | |
| paths.sort(key=lambda p: p["score"], reverse=True) | |
| return { | |
| "llm_reasoning": reasoning, | |
| "from_node": from_node, | |
| "total_directions": len(paths), | |
| "paths": paths | |
| } | |
| # ========== Tool Registration for LLM ========== | |
| def get_tools_schema(self) -> List[Dict[str, Any]]: | |
| """ | |
| Get tool definitions in a format suitable for LLM function calling. | |
| These schemas can be passed to LLM APIs like OpenAI's function calling, | |
| Anthropic's tool use, or Google Gemini's function declarations. | |
| Returns: | |
| List of tool definition dictionaries | |
| """ | |
| return [ | |
| { | |
| "name": "inspect_node", | |
| "description": "Get detailed information about a specific node on the board. USE THIS to verify node data instead of trying to interpret Arrays N and H yourself - this prevents hallucinations!", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "reasoning": { | |
| "type": "string", | |
| "description": "Explain WHY you're inspecting this specific node. What are you trying to verify or learn?" | |
| }, | |
| "node_id": { | |
| "type": "integer", | |
| "description": "The node ID to inspect (e.g., 10, 18, 40)" | |
| } | |
| }, | |
| "required": ["reasoning", "node_id"] | |
| } | |
| }, | |
| { | |
| "name": "find_best_nodes", | |
| "description": "Search for the best available nodes matching specific criteria. USE THIS instead of manually scanning the board - prevents missing opportunities!", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "reasoning": { | |
| "type": "string", | |
| "description": "Explain your search strategy. What kind of position are you looking for and why?" | |
| }, | |
| "min_pips": { | |
| "type": "integer", | |
| "description": "Minimum total pip value (probability). Good nodes have 10+, excellent have 12+", | |
| "default": 0 | |
| }, | |
| "must_have_resource": { | |
| "type": "string", | |
| "description": "Required resource type (e.g., 'Wheat', 'Ore', 'Brick', 'Wood', 'Sheep')", | |
| "nullable": True | |
| }, | |
| "exclude_blocked": { | |
| "type": "boolean", | |
| "description": "Skip nodes that cannot be built on (occupied or too close to other buildings)", | |
| "default": True | |
| }, | |
| "prefer_port": { | |
| "type": "boolean", | |
| "description": "Prioritize nodes with port access", | |
| "default": False | |
| }, | |
| "limit": { | |
| "type": "integer", | |
| "description": "Maximum number of results to return", | |
| "default": 10 | |
| } | |
| }, | |
| "required": ["reasoning"] | |
| } | |
| }, | |
| { | |
| "name": "analyze_path_potential", | |
| "description": "Analyze where a road path leads and what opportunities exist ahead. USE THIS to plan road building - shows ports and valuable nodes 1-2 steps away!", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "reasoning": { | |
| "type": "string", | |
| "description": "Explain your road-building strategy. Why analyze this path? What are you hoping to reach?" | |
| }, | |
| "from_node": { | |
| "type": "integer", | |
| "description": "Starting node ID (where you currently have a settlement/road)" | |
| }, | |
| "direction_node": { | |
| "type": "integer", | |
| "description": "Specific neighbor to analyze, or omit to see all directions", | |
| "nullable": True | |
| }, | |
| "max_depth": { | |
| "type": "integer", | |
| "description": "How many steps ahead to look (1 or 2)", | |
| "default": 2 | |
| } | |
| }, | |
| "required": ["reasoning", "from_node"] | |
| } | |
| } | |
| ] | |
| def execute_tool( | |
| self, | |
| tool_name: str, | |
| parameters: Dict[str, Any] | |
| ) -> Dict[str, Any]: | |
| """ | |
| Execute a tool by name with given parameters. | |
| This is the dispatcher for when the LLM calls a tool. | |
| Args: | |
| tool_name: Name of the tool to execute | |
| parameters: Parameters for the tool (as a dictionary) | |
| Returns: | |
| Tool execution result | |
| Raises: | |
| ValueError: If tool name is unknown | |
| """ | |
| if tool_name == "inspect_node": | |
| return self.inspect_node(**parameters) | |
| elif tool_name == "find_best_nodes": | |
| return self.find_best_nodes(**parameters) | |
| elif tool_name == "analyze_path_potential": | |
| return self.analyze_path_potential(**parameters) | |
| else: | |
| raise ValueError( | |
| f"Unknown tool: {tool_name}. " | |
| f"Available tools: inspect_node, find_best_nodes, analyze_path_potential" | |
| ) | |