Semver rename + single-call gateway
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+272
-4
@@ -17,7 +17,9 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -83,6 +85,45 @@ _kb_lock = threading.Lock()
|
||||
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
|
||||
_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 = {
|
||||
'sudo': [
|
||||
@@ -651,6 +692,163 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]:
|
||||
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]:
|
||||
try:
|
||||
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:
|
||||
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':
|
||||
q = user_text
|
||||
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'
|
||||
'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 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':
|
||||
@@ -827,6 +1055,7 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
||||
for c in calls:
|
||||
tool = c['tool']
|
||||
q = c.get('query', '')
|
||||
tool_input_desc = q # default description for trace
|
||||
if tool == 'web.search':
|
||||
out = tool_web_search(q)
|
||||
elif tool == 'minecraft.wiki_lookup':
|
||||
@@ -835,10 +1064,27 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
||||
out = tool_local_search(q)
|
||||
elif tool == 'local.read':
|
||||
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:
|
||||
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"
|
||||
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={tool_input_desc}\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)):
|
||||
@@ -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_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 = [
|
||||
{'role': 'system', 'content': _commands_prompt(session.mode)},
|
||||
*session.messages[-12:],
|
||||
|
||||
+18
-4
@@ -3687,11 +3687,25 @@ def _is_rcon_error(result: str) -> bool:
|
||||
|
||||
|
||||
_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"
|
||||
"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\": \"\"}."
|
||||
"If you cannot fix it, return {\"corrected\": \"\"}.\n\n"
|
||||
"Common fixes:\n"
|
||||
"- '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:
|
||||
|
||||
Reference in New Issue
Block a user