diff --git a/mc_aigod_paper.py b/mc_aigod_paper.py index 9a8a435..df56ea6 100644 --- a/mc_aigod_paper.py +++ b/mc_aigod_paper.py @@ -1772,10 +1772,13 @@ def _parse_llm_json(content: str) -> dict: If max_tokens cuts the response mid-string, we attempt to salvage whatever message and commands were already present. """ + log.info(f"Raw LLM content: {content[:500]}") + # Strip think blocks before parsing + content = re.sub(r'[\s\S]*?\s*', '', content) try: return json.loads(content) except json.JSONDecodeError: - log.warning("LLM response truncated — attempting repair") + log.warning(f"LLM response truncated — attempting repair. Raw: {content[:300]}") # Extract message if present, even if truncated msg_m = re.search(r'"message"\s*:\s*"((?:[^"\\]|\\.)*)', content) @@ -1853,8 +1856,16 @@ COMMANDS_SYSTEM_PROMPT = ( "decide what server commands to run (if any) as an act of divine judgment.\n\n" "Respond ONLY with a valid JSON object, nothing else:\n" "{\"commands\": [\"cmd1\", \"cmd2\"]}\n\n" - + (_GOD_SOUL + "\n\n" if _GOD_SOUL else "") - + "SYNTAX RULES:\n" + "IMPORTANT: Each command is ONE COMPLETE STRING. Example:\n" + "{\"commands\": [\"give slingshooter08 minecraft:diamond_sword 1\"]}\n" + "WRONG: {\"commands\": [\"give\", \"slingshooter08\", \"minecraft:diamond_sword\"]}\n\n" + "Rules:\n" + "- commands may be empty [] if no action is warranted.\n" + "- Reward humble prayers. Punish hubris or blasphemy. Be unpredictable.\n" + "- Consider the player's inventory and state.\n" + "- Powerful rewards (netherite, enchanted_golden_apple, totem) must be rare.\n" + "- kill is reserved for extreme blasphemy only.\n\n" + "SYNTAX RULES:\n" "- {player} = the praying player. You may target any other online player by name.\n" "- For give: syntax is always give minecraft: \n" "- Count comes LAST. Namespace prefix minecraft: is REQUIRED.\n" @@ -1879,9 +1890,12 @@ def build_sudo_commands_system_prompt(config=None) -> str: "Minecraft server commands. You do NOT roleplay.\n\n" "Respond ONLY with valid JSON:\n" "{\"commands\": [\"cmd1\", \"cmd2\"]}\n\n" + "IMPORTANT: Each command is ONE COMPLETE STRING. Example:\n" + "{\"commands\": [\"give slingshooter08 minecraft:diamond_sword 1\", \"effect give slingshooter08 minecraft:speed 60 1\"]}\n" + "WRONG: {\"commands\": [\"give\", \"slingshooter08\", \"minecraft:diamond_sword\", \"1\"]}\n\n" "Rules:\n" f"- Use commands from this whitelist only: {whitelist}.\n" - "- You may also output meta-commands starting with 'template ' for schematic workflows (not RCON).\n" + "- Do NOT output 'template' commands. Only output valid Minecraft RCON commands.\n" "- If the request cannot be mapped safely, return commands: [].\n" "- If player says 'me' or 'my', target the requesting player.\n" "- Do not broaden scope to @a/@e unless the user explicitly asks for all players/entities.\n" @@ -1893,11 +1907,7 @@ def build_sudo_commands_system_prompt(config=None) -> str: " If quantity is requested, output multiple summon commands.\n" "- Never use old NBT enchant syntax like {Enchantments:[...]}; use item[enchantments={...}] only.\n" "- Return commands only. No commentary.\n" - "- For build requests, prefer template workflow in one response when possible:\n" - " template search \n" - " template pick [name]\n" - " template build \n" - "- Keep template workflow concise (2-4 commands max).\n" + "- For build requests, use fill/setblock/clone commands. Do not use template commands.\n" "\n" "=== TELEPORT SYNTAX ===\n" "Same dimension: tp \n" @@ -2106,11 +2116,21 @@ def _gemini_call(system: str, user: str, config: dict, text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") - # Estimate cost (Gemini Flash Lite: $0.075/M input, $0.30/M output) + # Cost by model (per million tokens) + # flash-lite: $0.075 in, $0.30 out + # 2.5-flash: $0.15 in, $0.60 out + # 2.5-pro: $1.25 in, $10.00 out usage = data.get("usageMetadata", {}) input_tokens = usage.get("promptTokenCount", 500) output_tokens = usage.get("candidatesTokenCount", 150) - cost = (input_tokens / 1_000_000) * 0.075 + (output_tokens / 1_000_000) * 0.30 + pricing = { + "gemini-2.5-flash-lite": (0.075, 0.30), + "gemini-2.5-flash": (0.15, 0.60), + "gemini-2.0-flash": (0.10, 0.40), + "gemini-2.5-pro": (1.25, 10.00), + } + in_rate, out_rate = pricing.get(model, (0.15, 0.60)) + cost = (input_tokens / 1_000_000) * in_rate + (output_tokens / 1_000_000) * out_rate with _gemini_cost_lock: prev_dollar = int(_gemini_total_cost) @@ -2450,7 +2470,7 @@ def ask_god(player, prayer, context, config): config=config, fmt="json", temperature=0.3, # low temp for precise structured output - max_tokens=200, + max_tokens=600, ) cmd_result = _parse_llm_json(cmd_content) commands = cmd_result.get("commands") or [] @@ -2552,7 +2572,7 @@ def ask_god_intervention(context, config): config=config, fmt="json", temperature=0.3, - max_tokens=200, + max_tokens=600, ) commands = (_parse_llm_json(cmd_content) or {}).get("commands") or [] log.info(f"Intervention commands: {commands}") @@ -3242,7 +3262,10 @@ def _is_fire_intent(prompt: str) -> bool: p = (prompt or "").lower() if "tnt" in p: return False - return any(k in p for k in ("fire", "ignite", "burn", "flame")) + # Exclude firework/firecracker/fire_resistance/campfire etc + if any(k in p for k in ("firework", "firecracker", "fire_resistance", "fire_protection", "campfire", "fire aspect", "fire_aspect")): + return False + return any(k in p for k in ("set fire", "set on fire", "ignite", "burn it", "burn the", "light on fire")) def _normalize_sudo_command_shape(cmd: str, player: str) -> str: @@ -3458,6 +3481,63 @@ def _expand_tnt_commands_from_prompt(commands: list, prompt: str, player: str, c log.warning(f"Expanded TNT commands from {len(commands)} to {len(expanded)} (requested={requested}, cap={cap})") return expanded +_RCON_ERROR_PATTERNS = [ + "Unknown or incomplete command", + "Unknown game mode", + "Unknown item", + "Unknown enchantment", + "Unknown effect", + "Unknown entity", + "Incorrect argument", + "Expected whitespace", + "Expected ", + "Invalid or unknown", + "No such", + "<--[HERE]", +] + +def _is_rcon_error(result: str) -> bool: + """Check if RCON result indicates a command error (not just 'no player found').""" + if not result: + return False + r = result.lower() + # Benign non-errors + if "no player was found" in r or "that position is not loaded" in r: + return False + return any(p.lower() in r for p in _RCON_ERROR_PATTERNS) + + +_ERROR_CORRECTION_SYSTEM = ( + "A Minecraft command failed. Analyze the error and return a corrected command.\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\": \"\"}." +) + +def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str: + """Ask the model to fix a failed command. Returns corrected command or empty string.""" + try: + command_model = config.get("command_model", config["model"]) + content = _llm_call( + model=command_model, + system=_ERROR_CORRECTION_SYSTEM, + user=f"Failed command: {failed_cmd}\nError: {error_msg}", + config=config, + fmt="json", + temperature=0.1, + max_tokens=200, + ) + parsed = _parse_llm_json(content) + corrected = parsed.get("corrected") or "" + if corrected: + log.info(f"Error correction: '{failed_cmd}' -> '{corrected}'") + return corrected + except Exception as e: + log.warning(f"Error correction failed: {e}") + return "" + + def execute_response(response, context, config, praying_player=None): message = response.get("message") or "" commands = response.get("commands") or [] @@ -3549,6 +3629,20 @@ def execute_response(response, context, config, praying_player=None): log.info(f"Executing RCON: {resolved}") result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) log.info(f"RCON result: {result!r}") + + # Error correction: if RCON returns an error, ask model to fix and retry once + if config.get("error_correction", True) and result and _is_rcon_error(result): + log.info(f"RCON error detected, attempting correction: {result}") + corrected = _attempt_error_correction(resolved, result, config) + if corrected and corrected != resolved: + corrected_resolved, corrected_safe = validate_command( + corrected, context["online_players"], fallback, config + ) + if corrected_safe: + log.info(f"Retrying with corrected command: {corrected_resolved}") + retry_result = rcon(corrected_resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"Retry RCON result: {retry_result!r}") + if resolved.startswith("weather "): if "thunder" in resolved: config["_weather_state"] = "thunderstorm" elif "rain" in resolved: config["_weather_state"] = "rain" @@ -3889,7 +3983,6 @@ def process_sudo(player, prompt, config): context_hint = ( f"Requesting player: {player}\n" f"Online players: {', '.join(online) or 'none'}\n" - + "Template workflow commands available: template search ; template pick [name] ; template build \n" + (positions_block + "\n" if positions_block else "") + f"Natural language request: {prompt}\n" + get_sudo_history_block() @@ -3906,7 +3999,7 @@ def process_sudo(player, prompt, config): config=config, fmt="json", temperature=0.1, - max_tokens=180, + max_tokens=600, ) parsed = _parse_llm_json(content) return parsed.get("commands") or [] @@ -4052,6 +4145,20 @@ def process_sudo(player, prompt, config): result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) log.info(f"SUDO result: {result!r}") _sudo_trace(player, f"[SUDO RES] {str(result or '')[:180]}", config) + + # Error correction for sudo + if config.get("error_correction", True) and result and _is_rcon_error(result): + log.info(f"SUDO error detected, attempting correction: {result}") + corrected = _attempt_error_correction(resolved, result, config) + if corrected and corrected != resolved: + corrected_resolved, corrected_safe = validate_command(corrected, online, player, config) + if corrected_safe: + log.info(f"SUDO retry: {corrected_resolved}") + _sudo_trace(player, f"[SUDO RETRY] {corrected_resolved}", config) + result = rcon(corrected_resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"SUDO retry result: {result!r}") + resolved = corrected_resolved + executed.append(resolved) results_seen.append((resolved, str(result or ""))) time.sleep(0.2)