diff --git a/langgraph_gateway.py b/langgraph_gateway.py index 5931aa0..ee1eeb0 100644 --- a/langgraph_gateway.py +++ b/langgraph_gateway.py @@ -17,7 +17,9 @@ import hashlib import logging import os import re +import socket import sqlite3 +import struct import threading import time import uuid @@ -83,6 +85,45 @@ _kb_lock = threading.Lock() _kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []} _KB_ALLOWED_EXTS = {'.md', '.txt', '.json'} +# --------------------------------------------------------------------------- +# RCON world observation helper +# --------------------------------------------------------------------------- + +def _rcon_query(cmd: str, host: str = '127.0.0.1', port: int = 25577, + password: str = 'REDACTED_RCON', timeout: float = 5.0) -> str: + """Send a single RCON command and return the response text.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, int(port))) + + def pkt(req_id: int, pkt_type: int, payload: str) -> bytes: + p = payload.encode('utf-8') + b'\x00\x00' + return struct.pack(' Dict[str, Any]: + """Return RCON connection params from config.""" + return { + 'host': str(CFG.get('rcon_host', '127.0.0.1')), + 'port': int(CFG.get('rcon_port', 25577)), + 'password': str(CFG.get('rcon_password', 'REDACTED_RCON')), + } + COMMAND_PREFIXES_BY_MODE = { 'sudo': [ @@ -651,6 +692,163 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]: return {'ok': False, 'error': str(e), 'results': []} +# --------------------------------------------------------------------------- +# World observation tools (RCON-based) +# --------------------------------------------------------------------------- + +_ENTITY_TYPES_SCAN = [ + 'zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch', + 'phantom', 'drowned', 'husk', 'stray', 'pillager', 'vindicator', + 'cow', 'pig', 'sheep', 'chicken', 'horse', 'wolf', 'cat', 'villager', + 'iron_golem', 'snow_golem', 'bee', 'fox', 'rabbit', 'squid', + 'bat', 'parrot', 'turtle', 'dolphin', 'axolotl', 'goat', 'frog', + 'allay', 'sniffer', 'camel', 'armadillo', 'breeze', 'bogged', + 'item', 'experience_orb', 'armor_stand', 'minecart', 'boat', 'tnt', +] + + +def _parse_pos(rcon_output: str) -> Optional[List[float]]: + """Parse position from 'data get entity

Pos' RCON output.""" + m = re.search(r'\[(-?[\d.]+)d,\s*(-?[\d.]+)d,\s*(-?[\d.]+)d\]', rcon_output) + if m: + return [float(m.group(1)), float(m.group(2)), float(m.group(3))] + return None + + +def tool_world_player_info(player: str) -> Dict[str, Any]: + """Get player position, health, gamemode, and inventory summary.""" + rc = _rcon_cfg() + try: + pos_raw = _rcon_query(f'data get entity {player} Pos', **rc) + pos = _parse_pos(pos_raw) + health_raw = _rcon_query(f'data get entity {player} Health', **rc) + health_m = re.search(r'([\d.]+)f', health_raw) + health = float(health_m.group(1)) if health_m else None + gm_raw = _rcon_query(f'data get entity {player} playerGameType', **rc) + gm_m = re.search(r'(\d+)', gm_raw) + gamemode_map = {0: 'survival', 1: 'creative', 2: 'adventure', 3: 'spectator'} + gamemode = gamemode_map.get(int(gm_m.group(1)), 'unknown') if gm_m else None + inv_raw = _rcon_query(f'data get entity {player} Inventory', **rc) + # Count inventory items (each item in the list is an entry) + inv_count = inv_raw.count('{') if 'entity data' in inv_raw.lower() else 0 + return { + 'ok': True, + 'results': [{ + 'player': player, + 'position': {'x': pos[0], 'y': pos[1], 'z': pos[2]} if pos else None, + 'health': health, + 'max_health': 20.0, + 'gamemode': gamemode, + 'inventory_items': inv_count, + }], + } + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def tool_world_nearby_entities(player: str, radius: int = 30) -> Dict[str, Any]: + """Scan for entity types near a player within given radius.""" + rc = _rcon_cfg() + radius = min(max(radius, 5), 60) # clamp to 5-60 + try: + pos_raw = _rcon_query(f'data get entity {player} Pos', **rc) + pos = _parse_pos(pos_raw) + if not pos: + return {'ok': False, 'error': 'player not found or offline', 'results': []} + x, y, z = int(pos[0]), int(pos[1]), int(pos[2]) + found = [] + for etype in _ENTITY_TYPES_SCAN: + r = _rcon_query( + f'execute if entity @e[x={x},y={y},z={z},distance=..{radius},type=minecraft:{etype}]', + **rc + ) + if 'passed' in r.lower(): + count_m = re.search(r'Count:\s*(\d+)', r) + count = int(count_m.group(1)) if count_m else 1 + found.append({'type': etype, 'count': count}) + return { + 'ok': True, + 'results': [{ + 'player': player, + 'scan_center': {'x': x, 'y': y, 'z': z}, + 'radius': radius, + 'entities': found, + 'total': sum(e['count'] for e in found), + }], + } + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def tool_world_check_block(x: int, y: int, z: int, block_type: str) -> Dict[str, Any]: + """Check if a specific block type exists at coordinates.""" + rc = _rcon_cfg() + try: + if not block_type.startswith('minecraft:'): + block_type = f'minecraft:{block_type}' + r = _rcon_query(f'execute if block {x} {y} {z} {block_type}', **rc) + return { + 'ok': True, + 'results': [{ + 'position': {'x': x, 'y': y, 'z': z}, + 'block_type': block_type, + 'matches': 'passed' in r.lower(), + }], + } + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def tool_world_server_state() -> Dict[str, Any]: + """Get server-level state: players, time, worldborder, difficulty.""" + rc = _rcon_cfg() + try: + players_raw = _rcon_query('list', **rc) + time_raw = _rcon_query('time query daytime', **rc) + border_raw = _rcon_query('worldborder get', **rc) + diff_raw = _rcon_query('difficulty', **rc) + + # Parse player list + players = [] + m = re.search(r'online:\s*(.*)', players_raw) + if m and m.group(1).strip(): + players = [p.strip() for p in m.group(1).split(',') if p.strip()] + count_m = re.search(r'(\d+) of a max of (\d+)', players_raw) + count = int(count_m.group(1)) if count_m else len(players) + max_players = int(count_m.group(2)) if count_m else 20 + + # Parse time + time_m = re.search(r'(\d+)', time_raw) + ticks = int(time_m.group(1)) if time_m else 0 + # Convert to approximate in-game time (0=6:00, 6000=noon, 12000=18:00, 18000=midnight) + hours = ((ticks + 6000) % 24000) // 1000 + is_night = ticks >= 13000 or ticks < 0 + + # Parse worldborder + border_m = re.search(r'(\d+)', border_raw) + border = int(border_m.group(1)) if border_m else None + + # Parse difficulty + diff_m = re.search(r'difficulty is (\w+)', diff_raw) + difficulty = diff_m.group(1) if diff_m else 'unknown' + + return { + 'ok': True, + 'results': [{ + 'players_online': players, + 'player_count': count, + 'max_players': max_players, + 'time_ticks': ticks, + 'approx_hour': hours, + 'is_night': is_night, + 'worldborder_width': border, + 'difficulty': difficulty, + }], + } + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + def tool_local_search(query: str) -> Dict[str, Any]: try: rows = _kb_search(query, limit=5) @@ -673,6 +871,33 @@ def _tool_router(user_text: str, max_steps: int, mode: str, context: Dict[str, A if max_steps <= 0: return calls + # --- World observation tools (RCON-based) --- + # In sudo mode, get player info for position-aware command generation + world_enabled = bool(CFG.get('world_observation_enabled', True)) + if world_enabled and mode == 'sudo': + player = str((context or {}).get('player') or '').strip() + if not player: + # Try to extract player from server_state context + ss = (context or {}).get('server_state') or {} + players = ss.get('online_players') or [] + if players: + player = players[0] if len(players) == 1 else '' + if player: + # Always get player info for sudo -- position is critical for build/fill/tp commands + calls.append({'tool': 'world.player_info', 'player': player}) + # Scan nearby entities if the request involves mobs, entities, or environmental awareness + if any(k in text for k in [ + 'mob', 'monster', 'entity', 'creature', 'animal', 'kill', 'clear', + 'around', 'nearby', 'surround', 'area', 'here', 'near me', + 'spawn', 'summon', 'destroy', 'nuke', 'tnt', 'protect', 'safe', + ]): + calls.append({'tool': 'world.nearby_entities', 'player': player, 'radius': 30}) + + # In god/god_system mode, get server state for contextual awareness + if world_enabled and mode in ('god', 'god_system'): + calls.append({'tool': 'world.server_state'}) + + # --- Knowledge tools --- if mode == 'sudo': q = user_text req = str((context or {}).get('request') or '').strip() @@ -707,7 +932,10 @@ def _commands_prompt(mode: str) -> str: 'You may output template workflow meta-commands: template search , template pick [name], template build .\n' 'For build/make/create requests, prefer the template workflow instead of raw block-by-block commands.\n' 'If request is ambiguous or unsupported, choose a closest valid in-game workaround and keep scope bounded.\n' - 'If still unsafe/unknown, return empty commands.' + 'If still unsafe/unknown, return empty commands.\n' + 'WORLD STATE: If world.player_info or world.nearby_entities tool results are present, use the player\'s ' + 'actual coordinates for fill/setblock/tp commands instead of ~ ~ ~ relative coords when absolute positioning ' + 'is more reliable. Use nearby entity info to make contextually aware decisions.' ) if mode == 'god_system': @@ -827,6 +1055,7 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse: for c in calls: tool = c['tool'] q = c.get('query', '') + tool_input_desc = q # default description for trace if tool == 'web.search': out = tool_web_search(q) elif tool == 'minecraft.wiki_lookup': @@ -835,10 +1064,27 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse: out = tool_local_search(q) elif tool == 'local.read': out = tool_local_read(str(c.get('doc_id', '')), q) + elif tool == 'world.player_info': + p = str(c.get('player', '')) + tool_input_desc = p + out = tool_world_player_info(p) + elif tool == 'world.nearby_entities': + p = str(c.get('player', '')) + r = int(c.get('radius', 30)) + tool_input_desc = f'{p} radius={r}' + out = tool_world_nearby_entities(p, r) + elif tool == 'world.check_block': + bx, by, bz = int(c.get('x', 0)), int(c.get('y', 0)), int(c.get('z', 0)) + bt = str(c.get('block_type', '')) + tool_input_desc = f'{bx},{by},{bz} {bt}' + out = tool_world_check_block(bx, by, bz, bt) + elif tool == 'world.server_state': + tool_input_desc = 'server' + out = tool_world_server_state() else: out = {'ok': False, 'error': 'unknown tool', 'results': []} - tool_trace.append({'tool': tool, 'input': q, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))}) - tool_results_block += f"\nTOOL {tool} query={q}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n" + tool_trace.append({'tool': tool, 'input': tool_input_desc, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))}) + tool_results_block += f"\nTOOL {tool} query={tool_input_desc}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n" # localized retrieval hop: after index search, fetch one top document excerpt if tool == 'local.search' and out.get('ok') and out.get('results') and len(tool_trace) < max(0, min(req.max_tool_steps, 6)): @@ -849,37 +1095,59 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse: tool_trace.append({'tool': 'local.read', 'input': doc_id, 'ok': read_out.get('ok', False), 'results_count': len(read_out.get('results', []))}) tool_results_block += f"\nTOOL local.read doc_id={doc_id}\nRESULT={json.dumps(read_out, ensure_ascii=True)[:3000]}\n" - # Commands call - cmd_messages = [ - {'role': 'system', 'content': _commands_prompt(session.mode)}, - *session.messages[-12:], - {'role': 'user', 'content': user_blob + tool_results_block}, - ] - cmd_raw = _ollama_chat( - CFG.get('command_model', 'qwen3-coder:30b'), - cmd_messages, - fmt='json', - temperature=0.2, - max_tokens=220, - ) - cmd_parsed = _parse_json(cmd_raw) - commands = _sanitize_commands(cmd_parsed.get('commands') or [], session.mode) + # Single-call mode: one LLM call returns both commands and message + if CFG.get('single_call', False): + combined_prompt = _commands_prompt(session.mode) + if session.mode != 'sudo': + combined_prompt += '\n\nAlso include a "message" field with a dramatic in-character response.' - # Message call (not for sudo) - message = None - if session.mode != 'sudo': - msg_messages = [ - {'role': 'system', 'content': _message_prompt(session.mode)}, + sc_messages = [ + {'role': 'system', 'content': combined_prompt}, *session.messages[-12:], - {'role': 'user', 'content': user_blob + f"\nChosen commands: {commands}" + tool_results_block}, + {'role': 'user', 'content': user_blob + tool_results_block}, ] - message = _ollama_chat( - CFG.get('message_model', 'gemma3:12b'), - msg_messages, - fmt=None, - temperature=0.8, - max_tokens=500, - ).strip() + sc_raw = _ollama_chat( + CFG.get('command_model', CFG.get('model', 'mortdecai-v4')), + sc_messages, + fmt='json', + temperature=0.3, + max_tokens=600, + ) + sc_parsed = _parse_json(sc_raw) + commands = _sanitize_commands(sc_parsed.get('commands') or [], session.mode) + message = sc_parsed.get('message') or None + else: + # Two-call mode: separate command and message calls + cmd_messages = [ + {'role': 'system', 'content': _commands_prompt(session.mode)}, + *session.messages[-12:], + {'role': 'user', 'content': user_blob + tool_results_block}, + ] + cmd_raw = _ollama_chat( + CFG.get('command_model', 'qwen3-coder:30b'), + cmd_messages, + fmt='json', + temperature=0.2, + max_tokens=220, + ) + cmd_parsed = _parse_json(cmd_raw) + commands = _sanitize_commands(cmd_parsed.get('commands') or [], session.mode) + + # Message call (not for sudo) + message = None + if session.mode != 'sudo': + msg_messages = [ + {'role': 'system', 'content': _message_prompt(session.mode)}, + *session.messages[-12:], + {'role': 'user', 'content': user_blob + f"\nChosen commands: {commands}" + tool_results_block}, + ] + message = _ollama_chat( + CFG.get('message_model', 'gemma3:12b'), + msg_messages, + fmt=None, + temperature=0.8, + max_tokens=500, + ).strip() # Save assistant summary back to session memory session.messages.append({ diff --git a/mc_aigod_paper.py b/mc_aigod_paper.py index 6911a69..ab17af8 100644 --- a/mc_aigod_paper.py +++ b/mc_aigod_paper.py @@ -3687,11 +3687,25 @@ def _is_rcon_error(result: str) -> bool: _ERROR_CORRECTION_SYSTEM = ( - "A Minecraft command failed. Analyze the error and return a corrected command.\n" + "A Minecraft 1.21 command failed with an RCON error. Your job: return a DIFFERENT, corrected command.\n" + "Do NOT return the same command. Analyze the error and fix the syntax.\n\n" "Respond with JSON: {\"corrected\": \"the fixed command\"}\n" - "The command must be a single complete string. Use minecraft: prefix for all items/effects.\n" - "Use 1.21 syntax: enchantments use [enchantments={name:level}] NOT {Enchantments:[...]}.\n" - "If you cannot fix it, return {\"corrected\": \"\"}." + "If you cannot fix it, return {\"corrected\": \"\"}.\n\n" + "Common fixes:\n" + "- 'Incorrect argument for command' → wrong arg order or missing subcommand\n" + " xp player 100 → xp add player 100 levels\n" + " gamemode player survival → gamemode survival player\n" + "- 'Unknown item' → missing minecraft: prefix or wrong item name\n" + " give player diamond → give player minecraft:diamond 1\n" + " bed → minecraft:white_bed, log → minecraft:oak_log\n" + "- 'Expected whitespace' → count before enchantments or bad syntax\n" + " give player sword 1[enchantments=...] → give player sword[enchantments=...] 1\n" + "- 'Unknown enchantment/effect' → wrong name\n" + " sharp → sharpness, prot → protection, speed_boost → speed\n" + "- Old NBT format → use 1.21 component syntax\n" + " {Enchantments:[...]} → [enchantments={name:level}]\n" + "- Missing 'give' subcommand for effects\n" + " effect player speed → effect give player minecraft:speed 30 1\n" ) def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str: