Add retrieval-grounded sudo flow and execution feedback loop

This commit is contained in:
Claude Code
2026-03-17 19:53:32 -04:00
parent f4ce19db6d
commit 40b4da345a
6 changed files with 705 additions and 15 deletions
+329 -9
View File
@@ -76,6 +76,10 @@ first_login_seen = set()
SUDO_HISTORY_SIZE = 10
sudo_history: deque = deque() # entries: (ts, player, prompt, translated_cmds, executed_cmds)
# Sudo failure memory — last N failed command/result pairs, for anti-repeat context.
SUDO_FAILURE_SIZE = 20
sudo_failures: deque = deque() # entries: (ts, player, command, error)
_memory_lock = threading.Lock()
# Gateway client session mapping (player+mode -> session_id)
@@ -980,19 +984,71 @@ def get_last_sudo_executed_command(player: str) -> str:
return ""
def add_sudo_failure(player: str, command: str, error: str):
"""Record a failed sudo command/result so future prompts can avoid repeats."""
c = (command or "").strip()[:220]
e = re.sub(r'\s+', ' ', (error or "")).strip()[:220]
if not c or not e:
return
with _memory_lock:
sudo_failures.append((time.time(), player, c, e))
while len(sudo_failures) > SUDO_FAILURE_SIZE:
sudo_failures.popleft()
def get_sudo_failures_block(player: str = "") -> str:
"""Return recent failed sudo commands as anti-pattern context."""
with _memory_lock:
entries = list(sudo_failures)
if player:
entries = [e for e in entries if e[1] == player]
if not entries:
return ""
now = time.time()
lines = []
for ts, p, cmd, err in entries[-8:]:
mins = int((now - ts) / 60)
lines.append(f" [{mins}m ago] {p}: {cmd} -> {err}")
return "\n=== RECENT FAILED SUDO PATTERNS ===\n" + "\n".join(lines) + "\n"
def _bug_log_path(config) -> str:
return config.get("bug_log_path", "/var/log/mc_aigod_paper_bug.log")
def _read_recent_raw_log_lines(log_path: str, max_lines: int) -> list:
lines = deque(maxlen=max_lines)
lines = deque(maxlen=max_lines * 4)
noise = re.compile(r'RCON (?:Listener|Client)|Thread RCON Client')
try:
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
lines.append(line.rstrip('\n'))
clean = line.rstrip('\n')
if noise.search(clean):
continue
lines.append(clean)
except Exception as e:
return [f"<unable to read raw server log: {e}>"]
return list(lines)
return list(lines)[-max_lines:]
def _service_log_path(config) -> str:
return config.get("service_log_path", "/var/log/mc_aigod_paper.log")
def _read_recent_service_action_lines(path: str, max_lines: int) -> list:
lines = deque(maxlen=max_lines * 8)
keep = re.compile(
r'Prayer from|SUDO from|Executing RCON:|SUDO execute:|RCON result:|SUDO result:|BUG_LOG from'
)
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
clean = line.rstrip('\n')
if keep.search(clean):
lines.append(clean)
except Exception as e:
return [f"<unable to read service log: {e}>"]
return list(lines)[-max_lines:]
def _format_recent_event_lines(max_events: int) -> list:
@@ -1012,9 +1068,11 @@ def process_bug_log(player: str, description: str, config):
event_count = int(config.get("bug_log_event_lines", 40))
raw_count = int(config.get("bug_log_raw_lines", 120))
service_count = int(config.get("bug_log_service_lines", 30))
recent_events = _format_recent_event_lines(event_count)
raw_lines = _read_recent_raw_log_lines(config.get("log_path", ""), raw_count)
service_lines = _read_recent_service_action_lines(_service_log_path(config), service_count)
bug_path = _bug_log_path(config)
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
@@ -1033,6 +1091,11 @@ def process_bug_log(player: str, description: str, config):
bf.write("\n".join(recent_events) + "\n")
else:
bf.write("(no recent in-memory events captured)\n")
bf.write("\n-- RECENT AI ACTIONS (SERVICE LOG) --\n")
if service_lines:
bf.write("\n".join(service_lines) + "\n")
else:
bf.write("(no AI service lines available)\n")
bf.write("\n-- RECENT RAW SERVER LOG LINES --\n")
if raw_lines:
bf.write("\n".join(raw_lines) + "\n")
@@ -1382,6 +1445,8 @@ MOVEMENT:
NEVER use: tp <player> minecraft:the_nether (this is wrong syntax)
WORLD/ENVIRONMENT (affects all players):
SYNTAX: weather <clear|rain|thunder> [duration]
NOTE: 'storm' is invalid; if intent says storm/rainstorm/thunderstorm use thunder.
time set day
time set night
weather clear 6000
@@ -1443,9 +1508,12 @@ def build_system_prompt(config):
"- Do not ask a player to gather materials they clearly don't have access to or that are rare relative to their current situation.\n"
"- For give commands: use any valid Minecraft 1.21 item ID following the Item Naming Rules. Do not guess item IDs — consult the naming rules and common IDs list.\n"
"- For all other commands: only use forms shown in the Command Palette. Do not invent new command types.\n"
"- Weather values are only clear, rain, or thunder. If you mean storm/rainstorm/thunderstorm, output thunder.\n"
"- Reward humble, genuine prayers. Punish hubris, blasphemy, or naked greed.\n"
"- Repeated greedy demands after recent gifts should usually receive correction (rebuke, debuff, or symbolic punishment), not more rewards.\n"
"- Powerful rewards (netherite, enchanted_golden_apple, totem) must be rare.\n"
"- kill {target} is reserved for extreme blasphemy only.\n"
"- Avoid lethal accidents from vertical teleports. If using a high upward teleport as punishment or spectacle, pair it with slow_falling or resistance unless explicit execution is intended.\n"
"- When angered, chain commands: thunder + lightning + debuffs = divine wrath.\n\n"
"=== COMMAND PALETTE ===\n"
f"{COMMAND_PALETTE}\n"
@@ -1566,6 +1634,10 @@ COMMANDS_SYSTEM_PROMPT = (
"- kill is reserved for extreme blasphemy only.\n"
"- For give: syntax is always give <player> minecraft:<item_id> <count>\n"
"- Count comes LAST. Namespace prefix minecraft: is REQUIRED.\n"
"- For effects: use 'effect give <player> minecraft:<effect> <seconds> <amplifier>'.\n"
"- For weather use only clear/rain/thunder (NOT storm).\n"
"- Avoid accidental lethal movement in benevolent responses; do not launch players high unless explicitly asked.\n"
"- Do not use tp in helpful/benevolent responses unless the player explicitly requests movement/teleportation.\n"
"- Beds: white_bed not bed. Logs: oak_log not log. Wool: white_wool not wool.\n"
"- Chain commands for dramatic effect: thunder + lightning + blindness = wrath.\n\n"
+ "=== COMMAND PALETTE ===\n"
@@ -1588,8 +1660,14 @@ def build_sudo_commands_system_prompt(config=None) -> str:
"- You may also output meta-commands starting with 'template ' for schematic workflows (not RCON).\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"
"- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n"
"- You will also receive RECENT FAILED SUDO PATTERNS. Do not repeat those broken shapes.\n"
"- For give syntax: give <player> minecraft:<item_id> <count> (count LAST, namespace required)\n"
"- For effect syntax: effect give <player> minecraft:<effect> <seconds> <amplifier> [hideParticles]\n"
"- For summon tnt: summon minecraft:tnt <x> <y> <z> (NO trailing count number).\n"
" 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 <query>\n"
@@ -1683,6 +1761,7 @@ def build_message_system_prompt(config) -> str:
"Write a single spoken message to all players reacting to this prayer and action.\n"
"Respond with ONLY the message text — no JSON, no quotes, no formatting. "
"Be vivid and dramatic, with occasional godlike sarcasm and irony. Any length is fine.\n"
"For punishments, prefer fear and humiliation over accidental instant death unless destruction is explicitly intended.\n"
)
lore = config.get("god_lore", "")
if lore:
@@ -2070,6 +2149,53 @@ def _tp_inside_worldborder(cmd: str, config) -> bool:
return abs(x - cx) <= limit and abs(z - cz) <= limit
def _tp_safety_enabled(config) -> bool:
return bool(config.get("tp_safety_enabled", True))
def _extract_tp_target_y(cmd: str):
m = re.search(r'\btp\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)', cmd)
if not m:
return None, None
return m.group(1), m.group(3)
def _needs_vertical_tp_safety(resolved_cmd: str, config) -> tuple:
target, ytok = _extract_tp_target_y(resolved_cmd)
if not target or not ytok:
return False, ""
risky_delta = float(config.get("tp_safety_vertical_delta", 8.0))
risky_abs_y = float(config.get("tp_safety_absolute_y", 120.0))
if ytok.startswith("~"):
offs = ytok[1:].strip()
dy = float(offs) if offs else 0.0
if dy >= risky_delta:
return True, target
return False, target
try:
abs_y = float(ytok)
if abs_y >= risky_abs_y:
return True, target
except Exception:
pass
return False, target
def _tp_safety_prefix_commands(resolved_cmd: str, config) -> list:
if not _tp_safety_enabled(config):
return []
needs, target = _needs_vertical_tp_safety(resolved_cmd, config)
if not needs or not target:
return []
return [
f"effect give {target} minecraft:slow_falling 20 0",
f"effect give {target} minecraft:resistance 8 1",
]
def fix_give_command(cmd: str) -> str:
"""
Correct common LLM give command mistakes:
@@ -2167,6 +2293,80 @@ def fix_gamemode_command(cmd: str, fallback_player: str) -> str:
log.warning(f"Fixed gamemode syntax: '{cmd}' -> '{fixed}'")
return fixed
def fix_weather_command(cmd: str) -> str:
"""Normalize weather synonyms to valid Minecraft literals."""
raw = (cmd or "").strip()
fixed = re.sub(r'\bweather\s+(storm|rainstorm|thunderstorm)\b', 'weather thunder', raw, flags=re.IGNORECASE)
if fixed != raw:
log.warning(f"Fixed weather syntax: '{cmd}' -> '{fixed}'")
return fixed
def fix_fill_fire_command(cmd: str) -> str:
"""Fix legacy fill syntax like `... fire 0 replace air` for 1.21."""
raw = (cmd or "").strip()
m = re.match(r'^(fill\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+)(minecraft:)?(fire|soul_fire)\s+0\s+replace\s+air$', raw, flags=re.IGNORECASE)
if not m:
return raw
prefix, ns, block = m.groups()
block_id = f"minecraft:{block.lower()}" if not ns else f"{ns.lower()}{block.lower()}"
fixed = f"{prefix}{block_id} replace air"
log.warning(f"Fixed fill fire syntax: '{cmd}' -> '{fixed}'")
return fixed
def fix_bow_enchant_syntax(cmd: str) -> str:
"""Rewrite old bow Enchantments NBT to 1.21 component format."""
raw = (cmd or "").strip()
if "Enchantments:[" not in raw or "bow{" not in raw:
return raw
m = re.match(r'^(give\s+\S+\s+)(minecraft:)?bow\{Enchantments:\[(.+)\]\}\s+(\d+)$', raw)
if not m:
return raw
pre, _, body, count = m.groups()
ench = {}
for eid, lvl in re.findall(r'id:?\"?([a-z_]+)\"?,\s*lvl:([0-9]+)s?', body):
ench[eid] = lvl
if not ench:
return raw
ench_part = ",".join(f"{k}:{v}" for k, v in ench.items())
fixed = f"{pre}minecraft:bow[enchantments={{{ench_part}}}] {count}"
log.warning(f"Fixed bow enchant syntax: '{cmd}' -> '{fixed}'")
return fixed
def _repair_execute_tail(cmd: str, fallback_player: str) -> str:
"""Apply syntax repairers to `execute ... run <tail>` payloads."""
raw = (cmd or "").strip()
tail = raw
stack = []
# Peel execute wrappers up to a small depth.
for _ in range(4):
if not tail.startswith("execute "):
break
marker = " run "
idx = tail.find(marker)
if idx < 0:
break
stack.append(tail[: idx + len(marker)])
tail = tail[idx + len(marker):].strip()
fixed_tail = tail
fixed_tail = fix_give_command(fixed_tail)
fixed_tail = fix_effect_command(fixed_tail)
fixed_tail = fix_gamemode_command(fixed_tail, fallback_player)
fixed_tail = fix_weather_command(fixed_tail)
fixed_tail = fix_fill_fire_command(fixed_tail)
fixed_tail = fix_bow_enchant_syntax(fixed_tail)
if stack:
rebuilt = "".join(stack) + fixed_tail
if rebuilt != raw:
log.warning(f"Fixed execute-tail syntax: '{raw}' -> '{rebuilt}'")
return rebuilt
return raw
def validate_command(cmd, online_players, fallback_player, config=None):
"""Replace placeholders, auto-fix common give syntax errors, check safe prefix."""
resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player)
@@ -2176,6 +2376,10 @@ def validate_command(cmd, online_players, fallback_player, config=None):
resolved = fix_give_command(resolved)
resolved = fix_effect_command(resolved)
resolved = fix_gamemode_command(resolved, fallback_player)
resolved = fix_weather_command(resolved)
resolved = fix_fill_fire_command(resolved)
resolved = fix_bow_enchant_syntax(resolved)
resolved = _repair_execute_tail(resolved, fallback_player)
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):
@@ -2344,9 +2548,9 @@ def _repair_failed_sudo_commands(player: str, results_seen: list, config) -> lis
xx = x if x.startswith("~") else str(int(float(x)) + dx)
zz = z if z.startswith("~") else str(int(float(z)) + dz)
if x.startswith("~") and dx != 0:
xx = f"~{dx:+d}"
xx = f"~{dx}" if dx < 0 else f"~{dx}"
if z.startswith("~") and dz != 0:
zz = f"~{dz:+d}"
zz = f"~{dz}" if dz < 0 else f"~{dz}"
out.append(f"{prefix}summon minecraft:tnt {xx} {y} {zz}")
if len(out) >= max_retry:
return out
@@ -2363,6 +2567,53 @@ def _repair_failed_sudo_commands(player: str, results_seen: list, config) -> lis
return out[:max_retry]
def _expand_tnt_commands_from_prompt(commands: list, prompt: str, player: str, config) -> list:
"""If user asked for many TNT and model returned too few summons, expand boundedly."""
p = (prompt or "").lower()
if "tnt" not in p:
return commands
nums = [int(n) for n in re.findall(r'\b(\d{1,3})\b', p)]
if not nums:
return commands
requested = max(nums)
cap = int(config.get("sudo_tnt_max_commands", 80))
target = max(1, min(requested, cap))
if len(commands) >= target:
return commands
summons = [c for c in commands if "summon" in c and "tnt" in c]
if not summons:
return commands
base = summons[0]
prefix = ""
body = base
m_pref = re.match(rf'^(execute\s+at\s+{re.escape(player)}\s+run\s+)(.+)$', base)
if m_pref:
prefix = m_pref.group(1)
body = m_pref.group(2)
m = re.match(r'^summon\s+(?:minecraft:)?tnt\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+\{.*\})?$', body)
if not m:
return commands
x, y, z = m.groups()
expanded = []
for i in range(target):
dx = (i % 9) - 4
dz = (i // 9) - 4
xx = x
zz = z
if x.startswith("~"):
xx = "~" if dx == 0 else f"~{dx}"
if z.startswith("~"):
zz = "~" if dz == 0 else f"~{dz}"
expanded.append(f"{prefix}summon minecraft:tnt {xx} {y} {zz}")
log.warning(f"Expanded TNT commands from {len(commands)} to {len(expanded)} (requested={requested}, cap={cap})")
return expanded
def execute_response(response, context, config, praying_player=None):
message = response.get("message") or ""
commands = response.get("commands") or []
@@ -2421,6 +2672,17 @@ def execute_response(response, context, config, praying_player=None):
resolved, is_safe = validate_command(cmd, context["online_players"], fallback, config)
if not is_safe:
continue
safety_prefix = _tp_safety_prefix_commands(resolved, config)
for scmd in safety_prefix:
sresolved, safe_ok = validate_command(scmd, context["online_players"], fallback, config)
if not safe_ok:
continue
log.info(f"Executing RCON: {sresolved}")
sresult = rcon(sresolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"RCON result: {sresult!r}")
time.sleep(0.15)
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}")
@@ -2587,10 +2849,33 @@ def process_sudo(player, prompt, config):
if process_sudo_template_command(player, prompt, config):
return
def _looks_like_lookup_question(text: str) -> bool:
t = (text or "").strip().lower()
if not t:
return False
if t.endswith("?"):
return True
q_starts = (
"what ", "why ", "how ", "did ", "does ", "is ", "are ",
"can ", "could ", "should ", "would ", "where ", "when ",
)
if t.startswith(q_starts):
return True
if re.search(r'\b(that command|last command|did that|does that)\b', t):
return True
return False
def _local_lookup_fallback_answer(query: str, ref_cmd: str) -> str:
q = (query or "").lower()
rc = (ref_cmd or "").lower()
if "invisible" in q and "mob" in q and "invisibility" in rc:
return "Invisibility greatly reduces mob detection, but it does not make you perfectly undetectable at close range or while making noise/actions."
return ""
# Deterministic lookup mode: information only, no command execution.
low = prompt.lower().strip()
lookup_prefixes = ("lookup ", "search ", "wiki ", "explain ", "what is ", "how do i ", "how to ")
if low.startswith(lookup_prefixes):
if low.startswith(lookup_prefixes) or _looks_like_lookup_question(prompt):
query = prompt.strip()
if low.startswith("lookup "):
query = prompt[len("lookup "):].strip()
@@ -2614,6 +2899,7 @@ def process_sudo(player, prompt, config):
wiki_rows = _info_lookup_wiki(lookup_query)
web_rows = _info_lookup_web(lookup_query)
gateway_msg = ""
if wiki_rows:
_send_private(player, "minecraft.wiki:", config, "dark_aqua")
@@ -2647,6 +2933,7 @@ def process_sudo(player, prompt, config):
msg = (out.get("message") or "").strip()
trace = out.get("tool_trace") or []
if msg:
gateway_msg = msg
_send_private(player, "justification:", config, "dark_aqua")
for ln in re.split(r"\n+", msg)[:3]:
ln = ln.strip()
@@ -2659,9 +2946,14 @@ def process_sudo(player, prompt, config):
q = str(t.get("input", ""))[:80]
_send_private(player, f"- {tool}: {q}", config, "dark_gray")
if not wiki_rows and not web_rows:
_send_private(player, "No lookup results found.", config, "yellow")
if not wiki_rows and not web_rows and not gateway_msg:
fb = _local_lookup_fallback_answer(lookup_query, last_cmd)
if fb:
_send_private(player, f"- {fb}", config, "gray")
else:
_send_private(player, "No lookup results found.", config, "yellow")
except Exception as e:
log.warning(f"SUDO lookup failed for query={lookup_query!r}: {e}")
_send_private(player, f"[SUDO-LOOKUP] Failed: {e}", config, "red")
return
@@ -2717,6 +3009,7 @@ def process_sudo(player, prompt, config):
+ (positions_block + "\n" if positions_block else "")
+ f"Natural language request: {prompt}\n"
+ get_sudo_history_block()
+ get_sudo_failures_block(player)
)
command_model = config.get("command_model", config["model"])
@@ -2771,6 +3064,7 @@ def process_sudo(player, prompt, config):
"request": prompt,
"online_players": online,
"sudo_history": get_sudo_history_block(),
"sudo_failures": get_sudo_failures_block(player),
"mode": "sudo",
},
config=config,
@@ -2792,7 +3086,22 @@ def process_sudo(player, prompt, config):
)
return
build_intent = any(prompt.lower().strip().startswith(x) for x in ("build ", "make ", "create "))
def _is_build_intent(text: str) -> bool:
t = (text or "").strip().lower()
if t.startswith("build ") or t.startswith("create "):
return True
if "schem" in t or "schematic" in t or "template" in t:
return True
if t.startswith("make "):
build_nouns = (
"house", "base", "tower", "wall", "castle", "bridge", "barn",
"bar", "shop", "village", "room", "road", "farm", "portal",
"structure", "schem", "schematic", "template", "statue", "arena",
)
return any(n in t for n in build_nouns)
return False
build_intent = _is_build_intent(prompt)
has_template_cmd = any(isinstance(c, str) and c.lower().startswith("template ") for c in commands)
if build_intent and not has_template_cmd:
try:
@@ -2807,11 +3116,18 @@ def process_sudo(player, prompt, config):
low_prompt = prompt.lower().strip()
if any(low_prompt.startswith(x) for x in ("build ", "make ", "create ")):
max_cmds = max(max_cmds, int(config.get("sudo_build_max_commands", 6)))
if "tnt" in low_prompt:
nums = re.findall(r'\b(\d{1,3})\b', low_prompt)
if nums:
requested = max(int(n) for n in nums)
cap = int(config.get("sudo_tnt_max_commands", 80))
max_cmds = max(max_cmds, min(requested, cap))
commands = [
_normalize_sudo_command_shape(c, player)
for c in commands[:max_cmds]
]
commands = [c for c in commands if c]
commands = _expand_tnt_commands_from_prompt(commands, prompt, player, config)
if not commands:
add_sudo_history(player, prompt, [], [])
@@ -2895,6 +3211,10 @@ def process_sudo(player, prompt, config):
effective_hits = sum(1 for _, res in results_seen if _sudo_result_is_effective(res))
ineffective = (len(executed) == 0) or (effective_hits == 0)
for cmd, res in results_seen:
if not _sudo_result_is_effective(res):
add_sudo_failure(player, cmd, res)
_report_sudo_feedback(player, prompt, commands, results_seen, ineffective, config)
add_sudo_history(player, prompt, commands, executed)