Semver rename + single-call gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-20 21:37:37 -04:00
parent f10e901fe0
commit f243384d4e
2 changed files with 317 additions and 35 deletions
+272 -4
View File
@@ -17,7 +17,9 @@ import hashlib
import logging import logging
import os import os
import re import re
import socket
import sqlite3 import sqlite3
import struct
import threading import threading
import time import time
import uuid import uuid
@@ -83,6 +85,45 @@ _kb_lock = threading.Lock()
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []} _kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'} _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('<iii', len(p) + 8, req_id, pkt_type) + p
# Authenticate (type 3)
s.sendall(pkt(1, 3, password))
time.sleep(0.15)
s.recv(4096)
# Send command (type 2)
s.sendall(pkt(2, 2, cmd))
time.sleep(0.2)
r = s.recv(4096)
return r[12:-2].decode('utf-8', errors='replace')
except Exception as e:
return f'RCON error: {e}'
finally:
s.close()
def _rcon_cfg() -> 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 = { COMMAND_PREFIXES_BY_MODE = {
'sudo': [ 'sudo': [
@@ -651,6 +692,163 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]:
return {'ok': False, 'error': str(e), 'results': []} 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 <p> 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]: def tool_local_search(query: str) -> Dict[str, Any]:
try: try:
rows = _kb_search(query, limit=5) 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: if max_steps <= 0:
return calls 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': if mode == 'sudo':
q = user_text q = user_text
req = str((context or {}).get('request') or '').strip() 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 <query>, template pick <n> [name], template build <name>.\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' '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 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': if mode == 'god_system':
@@ -827,6 +1055,7 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
for c in calls: for c in calls:
tool = c['tool'] tool = c['tool']
q = c.get('query', '') q = c.get('query', '')
tool_input_desc = q # default description for trace
if tool == 'web.search': if tool == 'web.search':
out = tool_web_search(q) out = tool_web_search(q)
elif tool == 'minecraft.wiki_lookup': elif tool == 'minecraft.wiki_lookup':
@@ -835,10 +1064,27 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
out = tool_local_search(q) out = tool_local_search(q)
elif tool == 'local.read': elif tool == 'local.read':
out = tool_local_read(str(c.get('doc_id', '')), q) 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: else:
out = {'ok': False, 'error': 'unknown tool', 'results': []} 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_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={q}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n" 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 # 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)): 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,7 +1095,29 @@ 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_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" tool_results_block += f"\nTOOL local.read doc_id={doc_id}\nRESULT={json.dumps(read_out, ensure_ascii=True)[:3000]}\n"
# Commands call # 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.'
sc_messages = [
{'role': 'system', 'content': combined_prompt},
*session.messages[-12:],
{'role': 'user', 'content': user_blob + tool_results_block},
]
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 = [ cmd_messages = [
{'role': 'system', 'content': _commands_prompt(session.mode)}, {'role': 'system', 'content': _commands_prompt(session.mode)},
*session.messages[-12:], *session.messages[-12:],
+18 -4
View File
@@ -3687,11 +3687,25 @@ def _is_rcon_error(result: str) -> bool:
_ERROR_CORRECTION_SYSTEM = ( _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" "Respond with JSON: {\"corrected\": \"the fixed command\"}\n"
"The command must be a single complete string. Use minecraft: prefix for all items/effects.\n" "If you cannot fix it, return {\"corrected\": \"\"}.\n\n"
"Use 1.21 syntax: enchantments use [enchantments={name:level}] NOT {Enchantments:[...]}.\n" "Common fixes:\n"
"If you cannot fix it, return {\"corrected\": \"\"}." "- '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: def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str: