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
+10
View File
@@ -53,6 +53,10 @@ Gateway hardening currently included:
- SQLite-backed session persistence across gateway restarts
- Command sanitization at gateway return time (strips leading `/`, rejects prose/non-command lines)
- Mode-specific command family filtering and command dedupe/cap
- Localized knowledge retrieval for tool mode:
- local corpus under `/var/lib/mc-langgraph-gateway/knowledge`
- indexed search + document snippet retrieval (`local.search` -> `local.read`)
- optional bootstrap download of Minecraft/Paper/WorldEdit docs at startup
---
@@ -115,6 +119,8 @@ Info lookup mode via sudo:
Lookup mode is information-only (wiki/web retrieval + optional justification), and does not execute game commands.
For normal `sudo` translation, the gateway now also runs localized retrieval before command generation, so the model can ground command synthesis in local indexed docs rather than relying only on prompt memory.
These trigger multi-command `fill/setblock/give` sequences near the player and are optimized for Paper performance.
---
@@ -130,6 +136,10 @@ Important keys:
- `sudo_max_commands`: default `12` (higher for build bursts)
- `interventions_per_day`: default `24`
- `first_login_benevolence_max_commands`: default `12`
- `bug_log_path`: default `/var/log/mc_aigod_paper_bug.log`
- `bug_log_service_lines`: recent AI action lines attached to each bug entry
- `tp_safety_enabled`: auto-wrap risky vertical teleports with fall protection
- `tp_safety_vertical_delta`: relative Y threshold for teleport safety wrapper
---
+19
View File
@@ -119,7 +119,26 @@ This section captures decisions and context accumulated across conversations wit
- **Gamemode/effect sudo fix (2026-03-17):** Added `gamemode` to safe prefixes/whitelist and command palette, added syntax normalizer for malformed variants (`gameMode s`, short aliases, missing target) and execute-wrapped gamemode forms; confirmed valid RCON forms are `gamemode <mode> <player>` and `effect give <player> <effect> <duration> <amplifier> [hideParticles]`.
- **AI-driven build/template flow (2026-03-17):** `process_sudo` now defaults to non-deterministic build planning (legacy deterministic builder templates disabled unless `sudo_deterministic_build_templates=true`), supports AI-emitted `template ...` meta-commands in sudo execution loop, and includes an AI template planner override for build/make/create prompts when initial translation does not emit template workflow steps.
- **Failed-execution retry pipeline upgrade (2026-03-17):** sudo now runs a generic retry-repair pass on ineffective results before intent fallback. Added TNT-specific repair for malformed `summon tnt ... <count>` outputs (expands into bounded multiple summon commands) and invulnerability-effect repair to valid protection effects. Added `tnt` to destructive-intent keywords so TNT requests can trigger destructive fallback when needed.
- **TNT retry syntax correction (2026-03-17):** retry expansion for malformed TNT count commands initially emitted invalid relative coords with `~+1`/`~+2`; corrected generator to use valid forms (`~1`, `~2`) so repaired TNT summons no longer fail with `Expected double`.
- **User preference (2026-03-17):** prioritize context/prompt refinement and better model grounding over adding more deterministic hardcoded action paths.
- **Context-first refinement pass (2026-03-17):** added sudo failure memory (`sudo_failures`) and anti-repeat context block (`RECENT FAILED SUDO PATTERNS`) into paper sudo translator input and gateway sudo context payload; prompt rules now explicitly forbid repeated failed shapes, old enchant NBT syntax, and `summon tnt ... <count>` style outputs.
- **Prompt safety clarifications (2026-03-17):** updated paper God/system prompts to enforce `effect give ...` syntax, `weather clear|rain|thunder` only, and avoid accidental high vertical teleports in benevolent outputs unless explicitly requested.
- **Sudo scope grounding (2026-03-17):** paper sudo prompts now explicitly keep target scope narrow (`me/my` -> requesting player), discouraging broad `@a` outputs unless explicitly requested.
- **Unresolved post-update issues (2026-03-17):** (1) AI template planner override can trigger on non-build sudo intents (example: `make me invisible` routed into `template search/pick/build`), and (2) sudo follow-up questions like `did that command ...?` may not return an informational answer unless phrased with explicit lookup prefixes.
- **Resolved sudo routing issues (2026-03-17):** tightened build-intent detection so template planner override only triggers for actual structure/template intents (no longer on generic `make me ...` phrasing), and broadened sudo info-mode detection to treat natural question-style prompts (e.g., `did that command ...?`) as lookup queries even without explicit `lookup/search/wiki` prefix.
- **Remaining issues after routing fix deploy (2026-03-17):** (1) sudo still outputs old bow enchant NBT (`bow{Enchantments:[...]}`) in some runs and fails with trailing-data parse error, and (2) God prayer outputs still occasionally include unnecessary teleport actions (`tp ~ ~5 ~`) that users report as unhelpful side effects.
- **Bugfix pass after in-game retest (2026-03-17):** added command-repair normalizers for (a) old bow enchant NBT to 1.21 component syntax (`minecraft:bow[enchantments={...}]`), and (b) legacy fire fill syntax (`... fire 0 replace air` -> `... minecraft:fire replace air`), plus dynamic sudo command budget scaling for TNT prompts based on requested quantity (capped by `sudo_tnt_max_commands`, default 80).
- **Lookup and benevolent TP context refinements (2026-03-17):** lookup mode now has local fallback answering for contextual gameplay questions (e.g., invisibility vs mobs) when retrieval returns no hits, and prompts/gateway guidance now explicitly discourage teleport use in helpful responses unless movement is explicitly requested.
- **Post-retest fixes (2026-03-17):** added execute-tail syntax repair so command fixers apply inside `execute ... run ...` payloads (fixes old bow NBT + fill fire variants emitted under execute wrappers), and added TNT quantity expansion from prompt count for summon-heavy intents (bounded by `sudo_tnt_max_commands`, default 80) when model output under-produces summons.
- **Retest outcome (2026-03-17 late):** bow repair now works in live runs (`give strongest bow` successfully converted old NBT to `minecraft:bow[enchantments={...}]` and delivered item). Remaining issues: fire-spread requests still often execute as no-op/invalid hybrid fill chains (`execute ... run fill ... fire ...` with mixed legacy args), and TNT intent can still collapse into single-command failure then destructive fallback (large `fill ... air` + few TNT) instead of honoring requested count semantics.
- **God voice update (2026-03-17):** Increased default God persona emphasis on irony, dark humor, and sarcastic one-liners in both command and message system prompts (vanilla + Paper variants) while keeping command strictness unchanged.
- **Bug-log triage (2026-03-17):** `bug_log` entry confirmed an unintended-feeling movement reward in prayer flow (`execute as slingshooter08 run tp slingshooter08 ~ ~10 ~`) during a build-oriented prayer; prioritize pray-path teleport safety guards and intent alignment.
- **Bug follow-up (2026-03-17):** second `bug_log` entry reported God feeling "too nice" after greedy follow-up prayer; prompt context updated to bias repeated greedy demands toward corrective responses (rebuke/debuff/symbolic punishment) instead of extra rewards.
- **Teleport safety guard (2026-03-17):** added execution-time TP safety wrapper in prayer/intervention path (`execute_response`) to auto-apply `slow_falling` + `resistance` before risky upward teleports, reducing accidental player death from unintended high drops.
- **Bug-log signal upgrade (2026-03-17):** `bug_log` now writes filtered raw server log lines (RCON thread noise removed) and includes recent AI action lines from `/var/log/mc_aigod_paper.log` for better root-cause visibility.
- **Unaddressed bug triage (2026-03-17):** third `bug_log` entry ("output not in correct format") maps to prayer execution using invalid command `weather storm` (RCON: Incorrect argument). Current validator allows `weather` prefix but lacks argument normalization (`storm` -> `rain`) and no retry/repair pass for pray-path commands.
- **Weather normalization fix (2026-03-17):** added explicit prompt/context rule and code normalizer mapping `weather storm|rainstorm|thunderstorm` -> `weather thunder` before validation/execution (paper + vanilla), then redeployed both services.
- **New bug cluster observed (2026-03-17):** additional paper `bug_log` entries report failed command outcomes around selector-based targets and legacy enchant syntax in sudo (`minecraft:bow{Enchantments:...}`), indicating retry/repair still needed for 1.21 enchantment conversion and selector reliability feedback.
### Infrastructure decisions
+319 -5
View File
@@ -13,6 +13,7 @@ Execution safety remains in mc_aigod_paper.py.
"""
import json
import hashlib
import logging
import os
import re
@@ -20,6 +21,8 @@ import sqlite3
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@@ -76,12 +79,16 @@ class SessionState:
_sessions: Dict[str, SessionState] = {}
_sessions_lock = threading.Lock()
_kb_lock = threading.Lock()
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'}
COMMAND_PREFIXES_BY_MODE = {
'sudo': [
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ',
'kill ', 'summon ', 'tellraw ', 'worldborder ', 'fill ', 'setblock ',
'clone ',
'clone ', 'gamemode ', 'template ',
],
'god': [
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ',
@@ -106,6 +113,21 @@ def load_config() -> Dict[str, Any]:
'command_model': 'qwen3-coder:30b',
'tool_model': 'qwen2.5:1.5b',
'session_ttl_seconds': 21600,
'knowledge_base_dir': '/var/lib/mc-langgraph-gateway/knowledge',
'knowledge_index_file': '/var/lib/mc-langgraph-gateway/knowledge/index.json',
'knowledge_auto_index_on_start': True,
'knowledge_bootstrap_on_start': True,
'knowledge_bootstrap_urls': [
'https://minecraft.wiki/w/Commands/fill',
'https://minecraft.wiki/w/Commands/setblock',
'https://minecraft.wiki/w/Commands/clone',
'https://minecraft.wiki/w/Commands/summon',
'https://minecraft.wiki/w/Commands/execute',
'https://minecraft.wiki/w/TNT',
'https://minecraft.wiki/w/Explosion',
'https://minecraft.wiki/w/Tutorial:Worldedit',
],
'knowledge_max_doc_bytes': 200000,
}
@@ -114,6 +136,228 @@ DB_PATH = CFG.get('session_db_path', '/var/lib/mc-langgraph-gateway/sessions.db'
_db_lock = threading.Lock()
def _kb_root() -> str:
root = str(CFG.get('knowledge_base_dir', '/var/lib/mc-langgraph-gateway/knowledge')).strip()
return root or '/var/lib/mc-langgraph-gateway/knowledge'
def _kb_index_path() -> str:
path = str(CFG.get('knowledge_index_file', '')).strip()
if path:
return path
return os.path.join(_kb_root(), 'index.json')
def _kb_tokenize(text: str) -> List[str]:
toks = re.findall(r'[a-z0-9_]{2,}', (text or '').lower())
if not toks:
return []
out: List[str] = []
seen = set()
for t in toks:
if t in seen:
continue
seen.add(t)
out.append(t)
if len(out) >= 300:
break
return out
def _kb_html_to_text(html: str) -> str:
body = re.sub(r'(?is)<script.*?>.*?</script>', ' ', html or '')
body = re.sub(r'(?is)<style.*?>.*?</style>', ' ', body)
body = re.sub(r'(?is)<[^>]+>', ' ', body)
body = re.sub(r'\s+', ' ', body).strip()
return body
def _kb_slug(s: str) -> str:
n = re.sub(r'[^a-zA-Z0-9._-]+', '_', (s or '').strip())
n = n.strip('._-')
return (n[:80] or 'doc').lower()
def _kb_fetch_url(url: str) -> Dict[str, Any]:
max_bytes = int(CFG.get('knowledge_max_doc_bytes', 200000))
r = requests.get(url, timeout=25)
r.raise_for_status()
ct = (r.headers.get('content-type') or '').lower()
raw = r.content[:max_bytes]
if 'html' in ct:
text = _kb_html_to_text(raw.decode(errors='replace'))
else:
text = raw.decode(errors='replace')
title = ''
m = re.search(r'(?is)<title>(.*?)</title>', r.text if 'html' in ct else '')
if m:
title = re.sub(r'\s+', ' ', m.group(1)).strip()
return {'title': title, 'text': text}
def _kb_ingest_url(url: str) -> Dict[str, Any]:
parsed = urlparse(url)
host = (parsed.netloc or '').lower()
if host not in set(str(h).lower() for h in CFG.get('knowledge_allowed_hosts', [
'minecraft.wiki', 'www.minecraft.wiki', 'docs.papermc.io', 'intellectualsites.github.io', 'enginehub.org', 'worldedit.enginehub.org'
])):
return {'ok': False, 'error': f'host not allowed: {host}'}
try:
fetched = _kb_fetch_url(url)
text = (fetched.get('text') or '').strip()
if len(text) < 80:
return {'ok': False, 'error': 'document too short'}
title = fetched.get('title') or os.path.basename(parsed.path) or host
root = Path(_kb_root())
root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(url.encode()).hexdigest()[:12]
fname = f"{_kb_slug(title)}_{digest}.md"
out = root / fname
out.write_text(f"# {title}\n\nSource: {url}\n\n{text}\n", encoding='utf-8')
return {'ok': True, 'path': str(out), 'source': url, 'title': title}
except Exception as e:
return {'ok': False, 'error': str(e)}
def _kb_build_index() -> Dict[str, Any]:
root = Path(_kb_root())
root.mkdir(parents=True, exist_ok=True)
docs = []
for p in root.rglob('*'):
if not p.is_file() or p.suffix.lower() not in _KB_ALLOWED_EXTS:
continue
try:
text = p.read_text(encoding='utf-8', errors='replace')
except Exception:
continue
title = p.name
m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
if m:
title = m.group(1).strip()[:120]
snippet = re.sub(r'\s+', ' ', text[:800]).strip()
tokens = _kb_tokenize(text)
rel = str(p.relative_to(root))
doc_id = hashlib.sha1(rel.encode()).hexdigest()[:12]
docs.append({
'id': doc_id,
'path': rel,
'title': title,
'snippet': snippet[:260],
'tokens': tokens,
'mtime': p.stat().st_mtime,
})
out = {'generated_at': time.time(), 'docs': docs}
idx = Path(_kb_index_path())
idx.parent.mkdir(parents=True, exist_ok=True)
idx.write_text(json.dumps(out, ensure_ascii=True), encoding='utf-8')
with _kb_lock:
_kb_index_cache['loaded_at'] = time.time()
_kb_index_cache['docs'] = docs
return {'ok': True, 'count': len(docs), 'path': str(idx)}
def _kb_load_index(force: bool = False) -> List[Dict[str, Any]]:
with _kb_lock:
if _kb_index_cache.get('docs') and not force:
return list(_kb_index_cache['docs'])
idx = Path(_kb_index_path())
if not idx.exists():
_kb_build_index()
try:
data = json.loads(idx.read_text(encoding='utf-8'))
except Exception:
_kb_build_index()
data = json.loads(idx.read_text(encoding='utf-8'))
docs = data.get('docs') or []
with _kb_lock:
_kb_index_cache['loaded_at'] = time.time()
_kb_index_cache['docs'] = docs
return docs
def _kb_bootstrap_if_needed() -> None:
if not bool(CFG.get('knowledge_bootstrap_on_start', True)):
return
root = Path(_kb_root())
root.mkdir(parents=True, exist_ok=True)
existing = [p for p in root.rglob('*') if p.is_file() and p.suffix.lower() in _KB_ALLOWED_EXTS]
if existing:
return
urls = CFG.get('knowledge_bootstrap_urls', []) or []
if not urls:
return
ok = 0
for url in urls:
res = _kb_ingest_url(str(url))
if res.get('ok'):
ok += 1
log.info('knowledge bootstrap completed: %d/%d docs ingested', ok, len(urls))
def _kb_search(query: str, limit: int = 5) -> List[Dict[str, Any]]:
docs = _kb_load_index()
q_tokens = set(_kb_tokenize(query))
if not q_tokens:
return []
scored = []
q_lower = query.lower()
for d in docs:
tokens = set(d.get('tokens') or [])
overlap = len(q_tokens.intersection(tokens))
if overlap <= 0:
continue
score = overlap
if q_lower in (d.get('title', '').lower()):
score += 3
if q_lower in (d.get('snippet', '').lower()):
score += 1
scored.append((score, d))
scored.sort(key=lambda x: x[0], reverse=True)
out = []
for _, d in scored[:max(1, limit)]:
out.append({
'doc_id': d.get('id'),
'title': d.get('title'),
'path': d.get('path'),
'snippet': d.get('snippet'),
})
return out
def _kb_read(doc_id: str, query: str = '') -> Dict[str, Any]:
docs = _kb_load_index()
hit = None
for d in docs:
if d.get('id') == doc_id:
hit = d
break
if not hit:
return {'ok': False, 'error': 'doc_id not found', 'results': []}
full = Path(_kb_root()) / str(hit.get('path'))
if not full.exists():
return {'ok': False, 'error': 'file missing', 'results': []}
text = full.read_text(encoding='utf-8', errors='replace')
q = (query or '').strip().lower()
if q and q in text.lower():
idx = text.lower().find(q)
start = max(0, idx - 350)
end = min(len(text), idx + 650)
excerpt = text[start:end]
else:
excerpt = text[:1000]
return {
'ok': True,
'results': [{
'doc_id': doc_id,
'title': hit.get('title'),
'path': hit.get('path'),
'text': re.sub(r'\s+', ' ', excerpt).strip(),
}],
}
def _db_enabled() -> bool:
return bool(CFG.get('session_persistence_enabled', True))
@@ -407,13 +651,35 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]:
return {'ok': False, 'error': str(e), 'results': []}
def _tool_router(user_text: str, max_steps: int) -> List[Dict[str, Any]]:
def tool_local_search(query: str) -> Dict[str, Any]:
try:
rows = _kb_search(query, limit=5)
return {'ok': True, 'results': rows}
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def tool_local_read(doc_id: str, query: str = '') -> Dict[str, Any]:
try:
return _kb_read(doc_id, query)
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def _tool_router(user_text: str, max_steps: int, mode: str, context: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Very small bounded heuristic tool planner."""
text = user_text.lower()
calls: List[Dict[str, Any]] = []
if max_steps <= 0:
return calls
if mode == 'sudo':
q = user_text
req = str((context or {}).get('request') or '').strip()
if req:
q = req
calls.append({'tool': 'local.search', 'query': q})
if any(k in text for k in ['wiki', 'minecraft', 'item id', 'recipe', 'craft']):
calls.append({'tool': 'minecraft.wiki_lookup', 'query': user_text})
@@ -433,7 +699,15 @@ def _commands_prompt(mode: str) -> str:
'You are a Minecraft command translator. Return ONLY JSON: {"commands": ["..."]}.\n'
f'Allowed command prefixes: {allowed}.\n'
'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n'
'If unsafe/unknown, return empty commands.'
'Use TOOL results as your source of truth. Do not invent command syntax not supported by retrieved context.\n'
'Read context.sudo_failures and avoid repeating those exact failing patterns.\n'
'Never use old enchantment NBT {Enchantments:[...]} syntax; use item[enchantments={...}] format.\n'
'For TNT, never append a count to summon; use multiple summon commands instead.\n'
'Keep target scope narrow: if request is about "me/my", do not use @a unless explicitly requested.\n'
'You may output template workflow meta-commands: template search <query>, template pick <n> [name], template build <name>.\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 mode == 'god_system':
@@ -441,6 +715,7 @@ def _commands_prompt(mode: str) -> str:
'You are Minecraft divine system automation. Return ONLY JSON: {"commands": ["..."]}.\n'
f'Allowed command prefixes: {allowed}.\n'
'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n'
'Use valid 1.21 syntax: effect give <player> ..., and weather is clear/rain/thunder only.\n'
'This mode is for intervention/first-login events. Prefer benevolent or thematic world actions.\n'
'If you include kill commands, keep it to at most one player.'
)
@@ -449,6 +724,9 @@ def _commands_prompt(mode: str) -> str:
'You are Minecraft God command planner. Return ONLY JSON: {"commands": ["..."]}.\n'
f'Allowed command prefixes: {allowed}.\n'
'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n'
'Use valid 1.21 syntax: effect give <player> ..., and weather is clear/rain/thunder only.\n'
'Avoid accidental lethal vertical teleports in benevolent responses unless explicitly requested.\n'
'Do not use tp in helpful responses unless user explicitly asks for movement.\n'
'Balance benevolence and judgment based on context.\n'
'Use valid Minecraft command syntax only.'
)
@@ -524,25 +802,53 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
user_blob = f"message: {user_text}\ncontext: {context_json}"
session.messages.append({'role': req.role, 'content': user_blob})
# Feedback-only messages update session state without running LLM/tools.
if bool((req.context or {}).get('feedback_only', False)):
session.messages.append({
'role': 'assistant',
'content': json.dumps({'message': '', 'commands': []}, ensure_ascii=True)
})
_db_upsert_session(session)
return MessageResponse(message=None, commands=[], tool_trace=[])
_db_upsert_session(session)
tool_trace: List[Dict[str, Any]] = []
tool_results_block = ''
if req.allow_tools:
calls = _tool_router(user_text, max(0, min(req.max_tool_steps, 6)))
calls = _tool_router(
user_text,
max(0, min(req.max_tool_steps, 6)),
session.mode,
req.context or {},
)
for c in calls:
tool = c['tool']
q = c['query']
q = c.get('query', '')
if tool == 'web.search':
out = tool_web_search(q)
elif tool == 'minecraft.wiki_lookup':
out = tool_wiki_lookup(q)
elif tool == 'local.search':
out = tool_local_search(q)
elif tool == 'local.read':
out = tool_local_read(str(c.get('doc_id', '')), q)
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"
# 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)):
top = out['results'][0]
doc_id = str(top.get('doc_id', ''))
if doc_id:
read_out = tool_local_read(doc_id, q)
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)},
@@ -618,4 +924,12 @@ def close_session(session_id: str):
return {'closed': existed}
try:
_kb_bootstrap_if_needed()
if bool(CFG.get('knowledge_auto_index_on_start', True)):
meta = _kb_build_index()
log.info('knowledge index ready: %s docs=%s', meta.get('path'), meta.get('count'))
except Exception as e:
log.warning('knowledge bootstrap/index failed: %s', e)
_db_init()
+4
View File
@@ -41,6 +41,10 @@
"bug_log_path": "/var/log/mc_aigod_paper_bug.log",
"bug_log_event_lines": 40,
"bug_log_raw_lines": 120,
"bug_log_service_lines": 30,
"tp_safety_enabled": true,
"tp_safety_vertical_delta": 8,
"tp_safety_absolute_y": 120,
"sudo_build_max_commands": 6,
"sudo_deterministic_build_templates": false,
"tp_border_guard_enabled": true,
+328 -8
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:
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)
+24 -1
View File
@@ -5,5 +5,28 @@
"tool_model": "qwen2.5:1.5b",
"session_ttl_seconds": 21600,
"session_persistence_enabled": true,
"session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db"
"session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db",
"knowledge_base_dir": "/var/lib/mc-langgraph-gateway/knowledge",
"knowledge_index_file": "/var/lib/mc-langgraph-gateway/knowledge/index.json",
"knowledge_auto_index_on_start": true,
"knowledge_bootstrap_on_start": true,
"knowledge_max_doc_bytes": 200000,
"knowledge_allowed_hosts": [
"minecraft.wiki",
"www.minecraft.wiki",
"docs.papermc.io",
"intellectualsites.github.io",
"enginehub.org",
"worldedit.enginehub.org"
],
"knowledge_bootstrap_urls": [
"https://minecraft.wiki/w/Commands/fill",
"https://minecraft.wiki/w/Commands/setblock",
"https://minecraft.wiki/w/Commands/clone",
"https://minecraft.wiki/w/Commands/summon",
"https://minecraft.wiki/w/Commands/execute",
"https://minecraft.wiki/w/TNT",
"https://minecraft.wiki/w/Explosion",
"https://minecraft.wiki/w/Tutorial:Worldedit"
]
}