Async processing, single-call mode, validator tracking, think stripping

- ThreadPoolExecutor (3 workers) for concurrent prayer/sudo processing
- Single-call mode: one LLM call returns commands + message (config: single_call)
- Validator hit-rate tracking to /var/log/mc_validator_stats.json
- Strip <think> blocks from Qwen3 model output via regex
- Fresh LangGraph sessions (no history carryover)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-19 19:03:41 -04:00
parent 6f585e0021
commit 4706952c52
+156 -4
View File
@@ -7,6 +7,7 @@ Config: /etc/mc_aigod.json
"""
import json, os, random, re, socket, struct, threading, time, logging
from concurrent.futures import ThreadPoolExecutor
from collections import deque
from datetime import datetime
from typing import Any, Dict
@@ -24,6 +25,16 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
# --- Async request processing pool ---
_request_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="aigod")
def _safe_call(func, *args, **kwargs):
"""Wrapper for thread pool calls with error logging."""
try:
func(*args, **kwargs)
except Exception as e:
log.error(f"Async {func.__name__} failed: {e}", exc_info=True)
CONFIG_PATH = os.environ.get('MC_AIGOD_CONFIG', '/etc/mc_aigod_paper.json')
PRAY_PATTERNS = [
@@ -1202,6 +1213,10 @@ def write_training_audit(player: str, mode: str, user_message: str,
"server_type": config.get("server_type", "paper"),
"version": "1.21.x",
"online_players": context.get("online_players", []),
"player_details": context.get("player_details", {}),
"time_of_day": context.get("time_of_day", "unknown"),
"weather": context.get("weather", "unknown"),
"world_border": context.get("world_border"),
}
# Add player position if available
try:
@@ -2034,7 +2049,10 @@ def _llm_call(model: str, system: str, user: str, config: dict,
payload["format"] = fmt
r = requests.post(f"{config['ollama_url']}/api/chat", json=payload, timeout=timeout)
r.raise_for_status()
return r.json()["message"]["content"]
content = r.json()["message"]["content"]
# Strip <think>...</think> blocks from Qwen3 models
content = re.sub(r'<think>[\s\S]*?</think>\s*', '', content)
return content
# --- Gemini API cost tracking and call ---
@@ -2260,6 +2278,23 @@ def _gateway_actor(player: str, mode: str, config) -> str:
def _gateway_start_session(player: str, mode: str, config) -> str:
actor = _gateway_actor(player, mode, config)
key = _gateway_key(actor, mode)
# Fresh session mode: always start new session, no history carryover
if config.get("gateway_fresh_session", False):
url = config.get("langgraph_gateway_url", "http://127.0.0.1:8091")
timeout = int(config.get("langgraph_gateway_timeout", 45))
r = requests.post(
f"{url}/v1/session/start",
json={"player": actor, "mode": mode},
timeout=timeout,
)
r.raise_for_status()
sid = r.json()["session_id"]
with _gateway_lock:
_gateway_sessions[key] = sid
return sid
# Default: reuse existing session (carries history)
with _gateway_lock:
sid = _gateway_sessions.get(key)
if sid:
@@ -2370,7 +2405,42 @@ def ask_god(player, prayer, context, config):
log.info(f"Gateway god tool_trace={out.get('tool_trace', [])}")
return {"message": out.get("message"), "commands": out.get("commands") or []}
# --- Call 1: commands ---
# --- Single-call mode: one LLM call returns both commands and message ---
if config.get("single_call", False):
log.info(f"Single call ({command_model}) — {player}: {prayer[:60]} (history={len(history)//2})")
try:
content = _llm_call(
model=command_model,
system=GOD_SYSTEM_PROMPT,
user=user_msg,
config=config,
fmt="json",
temperature=0.5,
max_tokens=400,
)
result = _parse_llm_json(content)
commands = result.get("commands") or []
message = result.get("message") or ""
log.info(f"Commands decided: {commands}")
log.info(f"Message: {message[:200]}")
except Exception as e:
log.error(f"Single call failed: {e}")
commands = []
message = ""
try:
with open('/var/log/mc_aigod_paper_responses.log', 'a') as rf:
rf.write(
f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} prayer:{player} [single] ---\n"
f"COMMANDS: {commands}\n"
f"MESSAGE: {message}\n"
)
except Exception:
pass
return {"message": message, "commands": commands}
# --- Two-call mode (default): separate command and message calls ---
log.info(f"Commands call ({command_model}) — {player}: {prayer[:60]} (history={len(history)//2})")
try:
cmd_content = _llm_call(
@@ -2390,7 +2460,6 @@ def ask_god(player, prayer, context, config):
commands = []
# --- Call 2: message ---
# Tell the message model what was decided so it can write accordingly
if commands:
action_summary = f"You decided to execute these server commands: {commands}"
else:
@@ -2402,7 +2471,6 @@ def ask_god(player, prayer, context, config):
f"Now write your spoken message to all players."
)
# Include prayer history so God's voice is consistent
msg_messages = (
[{"role": "system", "content": build_message_system_prompt(config)}]
+ history
@@ -2422,6 +2490,8 @@ def ask_god(player, prayer, context, config):
r = requests.post(f"{config['ollama_url']}/api/chat", json=msg_payload, timeout=60)
r.raise_for_status()
message = r.json()["message"]["content"].strip()
# Strip think blocks
message = re.sub(r'<think>[\s\S]*?</think>\s*', '', message)
log.info(f"Message: {message[:200]}")
except Exception as e:
log.error(f"Message call failed: {e}")
@@ -3013,32 +3083,108 @@ def _repair_execute_tail(cmd: str, fallback_player: str) -> str:
return rebuilt
return raw
# --- Validator hit-rate tracking ---
_validator_stats_lock = threading.Lock()
_validator_stats_file = "/var/log/mc_validator_stats.json"
def _load_validator_stats():
try:
with open(_validator_stats_file) as f:
return json.load(f)
except:
return {"total": 0, "fixes": {}}
def _save_validator_stats(stats):
try:
with open(_validator_stats_file, "w") as f:
json.dump(stats, f)
except:
pass
_validator_stats = _load_validator_stats()
def _track_fix(fix_name, before, after):
"""Track when a validator fix actually changes a command."""
if before == after:
return
with _validator_stats_lock:
_validator_stats["fixes"].setdefault(fix_name, 0)
_validator_stats["fixes"][fix_name] += 1
# Save every 50 commands
if _validator_stats["total"] % 50 == 0:
_save_validator_stats(_validator_stats)
def validate_command(cmd, online_players, fallback_player, config=None):
"""Replace placeholders, auto-fix common give syntax errors, check safe prefix."""
with _validator_stats_lock:
_validator_stats["total"] += 1
resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player)
resolved = resolved.strip()
if resolved.startswith("/"):
resolved = resolved[1:]
# Fix 5: hallucinated commands (must run first — may drop or rewrite entire command)
before = resolved
resolved = fix_hallucinated_command(resolved)
_track_fix("hallucinated_command", before, resolved)
if not resolved:
return "", False
# Fix 1: @s → player name (RCON has no executor entity)
before = resolved
resolved = fix_at_s_selector(resolved, fallback_player)
_track_fix("at_s_selector", before, resolved)
# Fix 2: old NBT enchantment → 1.21 component syntax
before = resolved
resolved = fix_nbt_enchantment_syntax(resolved)
_track_fix("nbt_enchantment", before, resolved)
# Fix 3: strip invalid item components (display, durability, enc, etc.)
before = resolved
resolved = fix_invalid_item_components(resolved)
_track_fix("invalid_components", before, resolved)
# Fix 4: hallucinated effect names → closest valid effect
before = resolved
resolved = fix_hallucinated_effect(resolved)
_track_fix("hallucinated_effect", before, resolved)
# Existing fixes
before = resolved
resolved = fix_give_command(resolved)
_track_fix("give_syntax", before, resolved)
before = resolved
resolved = fix_effect_command(resolved)
_track_fix("effect_syntax", before, resolved)
before = resolved
resolved = fix_gamemode_command(resolved, fallback_player)
_track_fix("gamemode_syntax", before, resolved)
before = resolved
resolved = fix_weather_command(resolved)
_track_fix("weather_syntax", before, resolved)
before = resolved
resolved = fix_fill_fire_command(resolved)
_track_fix("fill_fire", before, resolved)
before = resolved
resolved = fix_bow_enchant_syntax(resolved)
_track_fix("bow_enchant", before, resolved)
before = resolved
resolved = _repair_execute_tail(resolved, fallback_player)
_track_fix("execute_tail", before, resolved)
# Periodic save
with _validator_stats_lock:
if _validator_stats["total"] % 50 == 0:
_save_validator_stats(_validator_stats)
caps = get_server_capabilities(config) if config else SERVER_CAPABILITIES[DEFAULT_SERVER_TYPE]
prefixes = caps["safe_prefixes"]
if not any(resolved.startswith(p) for p in prefixes):
@@ -4347,6 +4493,9 @@ def main():
player = m.group(1)
prompt = m.group(2).strip()
log.info(f"SUDO from {player}: {prompt}")
if config.get("async_processing", False):
_request_pool.submit(_safe_call, process_sudo, player, prompt, config)
else:
try:
process_sudo(player, prompt, config)
except Exception as e:
@@ -4365,6 +4514,9 @@ def main():
player = m.group(1)
prayer = m.group(2).strip()
log.info(f"Prayer from {player}: {prayer}")
if config.get("async_processing", False):
_request_pool.submit(_safe_call, process_prayer, player, prayer, config, cooldowns)
else:
try:
process_prayer(player, prayer, config, cooldowns)
except Exception as e: