GPU scheduler, 14-tool architecture, plugin deployment, event dispatcher

GPU Scheduler (gpu.sethpc.xyz):
- Live dashboard with 4 GPUs, training monitor, loss sparklines
- Preset-based job scheduler with 3 triggers (time, finish_training, cost)
- Model selection per GPU, pipeline configuration
- Tool self-play and training pipeline types
- Behind Google OAuth, live-refresh without page reload

Tool Architecture (14 tools):
- 3 new tools: world.nearby_entities, memory.read, memory.write
- 7 script.* tools: write, validate, execute, read, list, delete, schedule
- ScriptManager: full mcfunction datapack CRUD with RCON validation
- Training data: 1,430 tool examples (up from 1,159)

Plugin Deployment (paper-ai-25567):
- WorldGuard 7.0.12, CoreProtect CE 23.1, EssentialsX 2.21.2, Vault 1.7.3
- Fresh greenfield world reset
- 104 RCON-validated plugin training examples

Event Dispatcher:
- Watches server log for deaths, joins, advancements, PvP kills
- Configurable trigger probability and cooldowns per event type
- Deployed to dev server, fires god_system prompts on events
- 21 event-response training examples

Training Infrastructure:
- train_lora.py: --save-steps 50, --resume from checkpoint
- run_training.sh: stops Ollama, activates conda, restarts after
- Passwordless sudo for ollama services on steel141
- Dev server added to MCSManager with autoStart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-03-21 03:14:45 -04:00
parent 434589d098
commit da8f557219
34 changed files with 7822 additions and 2 deletions
+340
View File
@@ -0,0 +1,340 @@
"""
Event Dispatcher — watches server log events and triggers AI God responses.
Parses deaths, joins, leaves, advancements, PvP kills, and chat blasphemy
from the Minecraft server log. Each event type has a configurable trigger
probability and cooldown. When fired, formats the event as a god_system
prompt and sends it to the model.
Usage:
from agent.tools.event_dispatcher import EventDispatcher
dispatcher = EventDispatcher(config, llm_callback, rcon_fn)
dispatcher.process_log_line(line) # call for each new log line
Config keys:
event_triggers_enabled: bool (default true)
event_cooldown_global: int seconds (default 30)
event_cooldown_per_player: int seconds (default 120)
event_triggers: dict of event_type -> {probability, cooldown_override, enabled}
"""
import json
import logging
import random
import re
import threading
import time
from collections import defaultdict
from typing import Any, Callable, Dict, List, Optional
log = logging.getLogger(__name__)
# ── Event patterns ─────────────────────────────────────────────────────────
DEATH_PATTERNS = [
(re.compile(r'(\w+) fell from a high place'), "fall", "fell from a high place"),
(re.compile(r'(\w+) hit the ground too hard'), "fall", "hit the ground too hard"),
(re.compile(r'(\w+) was slain by (\w+)'), "mob_kill", "was slain by {1}"),
(re.compile(r'(\w+) was shot by (\w+)'), "ranged_kill", "was shot by {1}"),
(re.compile(r'(\w+) drowned'), "drown", "drowned"),
(re.compile(r'(\w+) tried to swim in lava'), "lava", "tried to swim in lava"),
(re.compile(r'(\w+) burned to death'), "fire", "burned to death"),
(re.compile(r'(\w+) went up in flames'), "fire", "went up in flames"),
(re.compile(r'(\w+) blew up'), "explosion", "blew up"),
(re.compile(r'(\w+) was blown up by (\w+)'), "explosion", "was blown up by {1}"),
(re.compile(r'(\w+) suffocated in a wall'), "suffocate", "suffocated in a wall"),
(re.compile(r'(\w+) starved to death'), "starve", "starved to death"),
(re.compile(r'(\w+) was killed by (\w+)'), "pvp_or_mob", "was killed by {1}"),
(re.compile(r'(\w+) was pricked to death'), "cactus", "was pricked to death"),
(re.compile(r'(\w+) withered away'), "wither", "withered away"),
(re.compile(r'(\w+) was impaled by (\w+)'), "trident", "was impaled by {1}"),
(re.compile(r'(\w+) fell out of the world'), "void", "fell out of the world"),
]
JOIN_PATTERN = re.compile(r'(\w+) joined the game')
LEAVE_PATTERN = re.compile(r'(\w+) left the game')
ADVANCEMENT_PATTERN = re.compile(r'(\w+) has made the advancement \[(.+?)\]')
CHALLENGE_PATTERN = re.compile(r'(\w+) has completed the challenge \[(.+?)\]')
# Players that killed other players — detect from death messages
PVP_KILLERS = {"was slain by", "was shot by", "was killed by"}
# ── Default trigger config ─────────────────────────────────────────────────
DEFAULT_TRIGGERS = {
"death_fall": {"probability": 0.40, "enabled": True},
"death_lava": {"probability": 0.35, "enabled": True},
"death_mob": {"probability": 0.25, "enabled": True},
"death_pvp": {"probability": 0.60, "enabled": True},
"death_drown": {"probability": 0.20, "enabled": True},
"death_fire": {"probability": 0.25, "enabled": True},
"death_explosion": {"probability": 0.30, "enabled": True},
"death_void": {"probability": 0.50, "enabled": True},
"death_starve": {"probability": 0.30, "enabled": True},
"death_other": {"probability": 0.15, "enabled": True},
"join_first": {"probability": 1.00, "enabled": True},
"join_returning": {"probability": 0.30, "enabled": True},
"leave_long": {"probability": 0.20, "enabled": True}, # played 2+ hours
"advancement": {"probability": 0.50, "enabled": True},
"challenge": {"probability": 0.70, "enabled": True},
"mass_death": {"probability": 0.80, "enabled": True}, # 2+ deaths in 30s
}
GLOBAL_COOLDOWN = 30 # seconds between any event trigger
PLAYER_COOLDOWN = 120 # seconds between triggers for same player
MASS_DEATH_WINDOW = 30 # seconds to detect mass deaths
class EventDispatcher:
def __init__(self, config: dict, llm_callback: Callable, rcon_fn: Callable):
"""
config: server config dict
llm_callback: function(prompt: str, mode: str) -> dict with {message, commands, reasoning}
rcon_fn: function(command: str) -> str
"""
self.config = config
self.llm_callback = llm_callback
self.rcon = rcon_fn
self.enabled = config.get("event_triggers_enabled", True)
# Merge user trigger config with defaults
self.triggers = dict(DEFAULT_TRIGGERS)
user_triggers = config.get("event_triggers", {})
for k, v in user_triggers.items():
if k in self.triggers:
self.triggers[k].update(v)
self.global_cooldown = config.get("event_cooldown_global", GLOBAL_COOLDOWN)
self.player_cooldown = config.get("event_cooldown_per_player", PLAYER_COOLDOWN)
# State
self._lock = threading.Lock()
self._last_global_trigger = 0.0
self._last_player_trigger: Dict[str, float] = defaultdict(float)
self._recent_deaths: List[tuple] = [] # (timestamp, player, cause)
self._player_join_times: Dict[str, float] = {}
self._known_players: set = set() # players we've seen before
self._known_players_path = config.get("event_known_players_path",
config.get("log_path", "").replace("logs/latest.log", "aigod_known_players.json"))
self._load_known_players()
def _load_known_players(self):
try:
with open(self._known_players_path) as f:
self._known_players = set(json.load(f))
except (FileNotFoundError, json.JSONDecodeError):
self._known_players = set()
def _save_known_players(self):
try:
with open(self._known_players_path, "w") as f:
json.dump(list(self._known_players), f)
except Exception:
pass
def _can_trigger(self, player: str) -> bool:
"""Check global and per-player cooldowns."""
now = time.time()
with self._lock:
if now - self._last_global_trigger < self.global_cooldown:
return False
if now - self._last_player_trigger[player] < self.player_cooldown:
return False
return True
def _mark_triggered(self, player: str):
now = time.time()
with self._lock:
self._last_global_trigger = now
self._last_player_trigger[player] = now
def _should_fire(self, event_type: str) -> bool:
"""Roll probability for this event type."""
trigger = self.triggers.get(event_type, {"probability": 0.0, "enabled": False})
if not trigger.get("enabled", False):
return False
return random.random() < trigger.get("probability", 0.0)
def _fire_event(self, event_text: str, player: str, event_type: str):
"""Send the event to the LLM and execute the response."""
self._mark_triggered(player)
log.info(f"[event] firing {event_type} for {player}: {event_text}")
try:
result = self.llm_callback(event_text, "god_system")
if not result:
return
message = result.get("message", "")
commands = result.get("commands", [])
# Send God's message
if message:
# Format for Minecraft chat
safe_msg = message.replace('"', '\\"')
prefix = self.config.get("god_chat_prefix", "[\u00a7d\u00a7lGOD\u00a7r]")
self.rcon(f'tellraw @a {{"text":"{prefix} {safe_msg}"}}')
# Execute commands
for cmd in commands[:8]: # cap at 8 commands
if isinstance(cmd, str) and cmd.strip():
try:
self.rcon(cmd.strip())
except Exception as e:
log.warning(f"[event] command failed: {cmd}{e}")
log.info(f"[event] {event_type}: sent message + {len(commands)} commands")
except Exception as e:
log.error(f"[event] LLM callback failed for {event_type}: {e}")
# ── Log line processing ────────────────────────────────────────────────
def process_log_line(self, line: str):
"""Parse a log line and dispatch any events."""
if not self.enabled:
return
# Strip the log prefix to get the message
# Format: [HH:MM:SS INFO]: message
msg_match = re.search(r'INFO\]: (.+)$', line)
if not msg_match:
return
msg = msg_match.group(1).strip()
self._check_death(msg)
self._check_join(msg)
self._check_leave(msg)
self._check_advancement(msg)
def _check_death(self, msg: str):
now = time.time()
for pattern, death_type, desc_template in DEATH_PATTERNS:
m = pattern.search(msg)
if not m:
continue
player = m.group(1)
killer = m.group(2) if m.lastindex and m.lastindex >= 2 else None
description = desc_template.format(**{str(i): m.group(i+1) for i in range(m.lastindex or 0)}) if m.lastindex else desc_template
# Track for mass death detection
with self._lock:
self._recent_deaths.append((now, player, death_type))
# Prune old deaths
self._recent_deaths = [(t, p, c) for t, p, c in self._recent_deaths if now - t < MASS_DEATH_WINDOW]
# Check for mass death
recent_count = len(self._recent_deaths)
# Determine event type
if killer and killer in self._known_players:
event_type = "death_pvp"
elif death_type == "fall":
event_type = "death_fall"
elif death_type == "lava":
event_type = "death_lava"
elif death_type == "drown":
event_type = "death_drown"
elif death_type in ("fire",):
event_type = "death_fire"
elif death_type == "explosion":
event_type = "death_explosion"
elif death_type == "void":
event_type = "death_void"
elif death_type == "starve":
event_type = "death_starve"
elif death_type in ("mob_kill", "ranged_kill", "pvp_or_mob"):
event_type = "death_mob"
else:
event_type = "death_other"
# Mass death override
if recent_count >= 2 and self._should_fire("mass_death") and self._can_trigger(player):
victims = list(set(p for _, p, _ in self._recent_deaths))
event_text = f"[EVENT:MASS_DEATH] {recent_count} players died within {MASS_DEATH_WINDOW} seconds: {', '.join(victims)}"
threading.Thread(target=self._fire_event, args=(event_text, player, "mass_death"), daemon=True).start()
with self._lock:
self._recent_deaths.clear()
return
if not self._can_trigger(player):
return
if not self._should_fire(event_type):
return
event_text = f"[EVENT:DEATH] Player {player} {description}"
if killer:
event_text += f" (killer: {killer})"
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
return
def _check_join(self, msg: str):
m = JOIN_PATTERN.search(msg)
if not m:
return
player = m.group(1)
self._player_join_times[player] = time.time()
is_first = player not in self._known_players
if is_first:
self._known_players.add(player)
self._save_known_players()
event_type = "join_first"
event_text = f"[EVENT:JOIN] Player {player} joined the game for the first time"
else:
event_type = "join_returning"
event_text = f"[EVENT:JOIN] Player {player} joined the game (returning player)"
if not self._can_trigger(player):
return
if not self._should_fire(event_type):
return
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
def _check_leave(self, msg: str):
m = LEAVE_PATTERN.search(msg)
if not m:
return
player = m.group(1)
join_time = self._player_join_times.pop(player, None)
if join_time:
session_minutes = (time.time() - join_time) / 60
if session_minutes >= 120: # 2+ hours
event_type = "leave_long"
event_text = f"[EVENT:LEAVE] Player {player} left the game after {session_minutes:.0f} minutes"
if not self._can_trigger(player):
return
if not self._should_fire(event_type):
return
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
def _check_advancement(self, msg: str):
m = ADVANCEMENT_PATTERN.search(msg) or CHALLENGE_PATTERN.search(msg)
if not m:
return
player = m.group(1)
advancement = m.group(2)
is_challenge = bool(CHALLENGE_PATTERN.search(msg))
event_type = "challenge" if is_challenge else "advancement"
event_text = f"[EVENT:ADVANCEMENT] Player {player} earned [{advancement}]"
if not self._can_trigger(player):
return
if not self._should_fire(event_type):
return
threading.Thread(target=self._fire_event, args=(event_text, player, event_type), daemon=True).start()
+392
View File
@@ -0,0 +1,392 @@
"""
Mortdecai Script Manager — mcfunction datapack CRUD + validation + scheduling.
Gives the model full control over a Minecraft datapack:
script.write — create/overwrite mcfunction files
script.validate — dry-run commands through RCON before writing
script.execute — run a function via RCON
script.read — read an existing script
script.list — list all scripts in the datapack
script.delete — remove a script
script.schedule — add to tick.json or load.json
Datapack structure:
<server>/world/datapacks/mortdecai/
pack.mcmeta
data/mortdecai/
function/ ← mcfunction files live here
tags/function/ ← tick.json, load.json for scheduling
Usage:
from agent.tools.script_manager import ScriptManager
mgr = ScriptManager(config)
result = mgr.write("build_house", ["fill ~-5 ~ ~-5 ~5 ~4 ~5 oak_planks hollow", ...])
result = mgr.validate(["give @s minecraft:diamond 1", "bad command here"])
result = mgr.execute("build_house", as_player="slingshooter08")
"""
import json
import os
import re
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
_lock = threading.Lock()
MAX_SCRIPT_LINES = 200
MAX_SCRIPTS = 50
NAMESPACE = "mortdecai"
PACK_MCMETA = {
"pack": {
"pack_format": 48,
"description": "Mortdecai AI-generated functions"
}
}
class ScriptManager:
def __init__(self, config: dict):
"""
config keys:
datapack_path: str — path to the datapack root (e.g. /opt/paper-ai-25567/world/datapacks/mortdecai)
rcon_fn: callable — function(command: str) -> str that executes an RCON command and returns the result
"""
self.base = Path(config.get("datapack_path", "/opt/paper-ai-25567/world/datapacks/mortdecai"))
self.func_dir = self.base / "data" / NAMESPACE / "function"
self.tags_dir = self.base / "data" / "minecraft" / "tags" / "function"
self.rcon = config.get("rcon_fn")
self._ensure_datapack()
def _ensure_datapack(self):
"""Create the datapack structure if it doesn't exist."""
with _lock:
self.func_dir.mkdir(parents=True, exist_ok=True)
self.tags_dir.mkdir(parents=True, exist_ok=True)
mcmeta = self.base / "pack.mcmeta"
if not mcmeta.exists():
with open(mcmeta, "w") as f:
json.dump(PACK_MCMETA, f, indent=2)
# Ensure tick.json and load.json exist
for tag_name in ("tick", "load"):
tag_file = self.tags_dir / f"{tag_name}.json"
if not tag_file.exists():
with open(tag_file, "w") as f:
json.dump({"values": []}, f, indent=2)
def _sanitize_name(self, name: str) -> str:
"""Sanitize a script name to a valid mcfunction filename."""
name = name.lower().strip()
name = re.sub(r'[^a-z0-9_/]', '_', name)
name = re.sub(r'_+', '_', name).strip('_')
return name or "unnamed"
def _func_path(self, name: str) -> Path:
return self.func_dir / f"{self._sanitize_name(name)}.mcfunction"
# ── Write ──────────────────────────────────────────────────────────────
def write(self, name: str, commands: List[str], description: str = "") -> Dict[str, Any]:
"""Write a mcfunction file. Returns {ok, path, lines}."""
name = self._sanitize_name(name)
if len(commands) > MAX_SCRIPT_LINES:
return {"ok": False, "error": f"Too many lines ({len(commands)}). Max {MAX_SCRIPT_LINES}."}
# Check total script count
existing = list(self.func_dir.glob("*.mcfunction"))
if len(existing) >= MAX_SCRIPTS and not self._func_path(name).exists():
return {"ok": False, "error": f"Too many scripts ({len(existing)}). Max {MAX_SCRIPTS}. Delete some first."}
# Strip leading slashes from commands
cleaned = []
for cmd in commands:
cmd = cmd.strip()
if cmd.startswith("/"):
cmd = cmd[1:]
if cmd and not cmd.startswith("#"):
cleaned.append(cmd)
elif cmd.startswith("#"):
cleaned.append(cmd) # preserve comments
lines = []
if description:
lines.append(f"# {description}")
lines.append(f"# Generated by Mortdecai")
lines.append("")
lines.extend(cleaned)
path = self._func_path(name)
with _lock:
# Support subdirectories
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
return {"ok": True, "path": f"{NAMESPACE}:{name}", "lines": len(cleaned)}
# ── Validate ───────────────────────────────────────────────────────────
def validate(self, commands: List[str]) -> Dict[str, Any]:
"""Dry-run validate commands through RCON. Returns per-line results."""
if not self.rcon:
return {"ok": False, "error": "No RCON connection available"}
errors = []
passed = 0
for i, cmd in enumerate(commands):
cmd = cmd.strip()
if not cmd or cmd.startswith("#"):
passed += 1
continue
if cmd.startswith("/"):
cmd = cmd[1:]
# Skip commands that use relative coords — can't validate without context
# But we can still check the command structure
if "~" in cmd or "^" in cmd:
# Basic syntax check: try to parse the command name at least
parts = cmd.split()
base_cmd = parts[0] if parts else ""
valid_bases = {
"give", "tp", "teleport", "effect", "kill", "summon", "setblock",
"fill", "clone", "weather", "time", "gamemode", "clear", "xp",
"experience", "execute", "playsound", "title", "particle", "say",
"tellraw", "scoreboard", "team", "tag", "data", "attribute",
"enchant", "spreadplayers", "spawnpoint", "setworldspawn",
"worldborder", "gamerule", "difficulty", "forceload", "locate",
"place", "ride", "damage", "return", "schedule", "function",
"bossbar", "recipe", "advancement", "loot", "item", "trigger",
}
if base_cmd in valid_bases:
passed += 1
else:
errors.append({"line": i + 1, "command": cmd, "error": f"Unknown command: {base_cmd}"})
continue
try:
result = self.rcon(cmd)
is_error = any(e in result for e in ("<--[HERE]", "Unknown", "Incorrect", "Expected", "Invalid"))
if is_error:
errors.append({"line": i + 1, "command": cmd, "error": result[:200]})
else:
passed += 1
except Exception as e:
errors.append({"line": i + 1, "command": cmd, "error": str(e)})
return {
"valid": len(errors) == 0,
"total": len(commands),
"passed": passed,
"errors": errors,
}
# ── Execute ────────────────────────────────────────────────────────────
def execute(self, name: str, as_player: str = None) -> Dict[str, Any]:
"""Execute a mcfunction via RCON."""
name = self._sanitize_name(name)
path = self._func_path(name)
if not path.exists():
return {"ok": False, "error": f"Script '{name}' not found"}
if not self.rcon:
return {"ok": False, "error": "No RCON connection available"}
# Reload datapack to pick up any changes
try:
self.rcon("reload")
except Exception:
pass
# Execute the function
if as_player:
cmd = f"execute as {as_player} at @s run function {NAMESPACE}:{name}"
else:
cmd = f"function {NAMESPACE}:{name}"
try:
result = self.rcon(cmd)
is_error = any(e in result for e in ("<--[HERE]", "Unknown", "Incorrect"))
return {"ok": not is_error, "result": result[:500]}
except Exception as e:
return {"ok": False, "error": str(e)}
# ── Read ───────────────────────────────────────────────────────────────
def read(self, name: str) -> Dict[str, Any]:
"""Read script contents."""
name = self._sanitize_name(name)
path = self._func_path(name)
if not path.exists():
return {"ok": False, "error": f"Script '{name}' not found"}
with open(path) as f:
lines = f.read().strip().split("\n")
# Filter out comments and blanks for the command list
commands = [l for l in lines if l.strip() and not l.strip().startswith("#")]
return {"ok": True, "commands": commands, "lines": len(commands), "raw": lines}
# ── List ───────────────────────────────────────────────────────────────
def list_scripts(self) -> Dict[str, Any]:
"""List all scripts in the datapack."""
scripts = []
# Read tick/load schedules
scheduled = {}
for tag_name in ("tick", "load"):
tag_file = self.tags_dir / f"{tag_name}.json"
if tag_file.exists():
with open(tag_file) as f:
tag_data = json.load(f)
for val in tag_data.get("values", []):
func_name = val.replace(f"{NAMESPACE}:", "")
scheduled[func_name] = tag_name
for path in sorted(self.func_dir.glob("**/*.mcfunction")):
rel = path.relative_to(self.func_dir)
name = str(rel).replace(".mcfunction", "").replace(os.sep, "/")
with open(path) as f:
line_count = sum(1 for l in f if l.strip() and not l.strip().startswith("#"))
scripts.append({
"name": name,
"lines": line_count,
"scheduled": scheduled.get(name, "none"),
})
return {"scripts": scripts}
# ── Context (for prompt injection) ───────────────────────────────────
def format_script_context(self) -> str:
"""Format available scripts for LLM context injection.
Injected into the system prompt so the model knows what scripts exist
without needing to call script.list every time."""
info = self.list_scripts()
scripts = info.get("scripts", [])
if not scripts:
return ""
lines = ["=== AVAILABLE SCRIPTS ==="]
for s in scripts:
sched = f", {s['scheduled']}" if s["scheduled"] != "none" else ""
# Try to read the description comment from the file
desc = ""
path = self._func_path(s["name"])
if path.exists():
with open(path) as f:
first_line = f.readline().strip()
if first_line.startswith("# ") and "Generated by" not in first_line:
desc = f"{first_line[2:]}"
lines.append(f" {s['name']} ({s['lines']} lines{sched}){desc}")
lines.append("Use script.execute to run, script.read to inspect, script.write to create new.")
return "\n".join(lines)
# ── Delete ─────────────────────────────────────────────────────────────
def delete(self, name: str) -> Dict[str, Any]:
"""Delete a script and remove from schedules."""
name = self._sanitize_name(name)
path = self._func_path(name)
if not path.exists():
return {"ok": False, "error": f"Script '{name}' not found"}
with _lock:
path.unlink()
# Remove from tick/load if scheduled
for tag_name in ("tick", "load"):
tag_file = self.tags_dir / f"{tag_name}.json"
if tag_file.exists():
with open(tag_file) as f:
tag_data = json.load(f)
full_name = f"{NAMESPACE}:{name}"
if full_name in tag_data.get("values", []):
tag_data["values"].remove(full_name)
with open(tag_file, "w") as f:
json.dump(tag_data, f, indent=2)
return {"ok": True}
# ── Schedule ───────────────────────────────────────────────────────────
def schedule(self, name: str, schedule_type: str) -> Dict[str, Any]:
"""Add a script to tick.json or load.json."""
name = self._sanitize_name(name)
path = self._func_path(name)
if not path.exists():
return {"ok": False, "error": f"Script '{name}' not found"}
if schedule_type not in ("tick", "load"):
return {"ok": False, "error": f"Invalid schedule type: {schedule_type}. Use 'tick' or 'load'."}
tag_file = self.tags_dir / f"{schedule_type}.json"
full_name = f"{NAMESPACE}:{name}"
with _lock:
with open(tag_file) as f:
tag_data = json.load(f)
if full_name not in tag_data.get("values", []):
tag_data.setdefault("values", []).append(full_name)
with open(tag_file, "w") as f:
json.dump(tag_data, f, indent=2)
# Reload to activate
if self.rcon:
try:
self.rcon("reload")
except Exception:
pass
return {"ok": True}
# ── Tool handler dispatch ──────────────────────────────────────────────────
def handle_script_tool(config: dict, tool_name: str, arguments: dict) -> Dict[str, Any]:
"""
Dispatch a script.* tool call to the ScriptManager.
Called from the inference loop when the model emits a script tool call.
"""
mgr = ScriptManager(config)
action = tool_name.split(".")[-1] if "." in tool_name else tool_name
if action == "write":
return mgr.write(
name=arguments.get("name", "unnamed"),
commands=arguments.get("commands", []),
description=arguments.get("description", ""),
)
elif action == "validate":
return mgr.validate(arguments.get("commands", []))
elif action == "execute":
return mgr.execute(
name=arguments.get("name", ""),
as_player=arguments.get("as_player"),
)
elif action == "read":
return mgr.read(arguments.get("name", ""))
elif action == "list":
return mgr.list_scripts()
elif action == "delete":
return mgr.delete(arguments.get("name", ""))
elif action == "schedule":
return mgr.schedule(
name=arguments.get("name", ""),
schedule_type=arguments.get("type", ""),
)
else:
return {"ok": False, "error": f"Unknown script action: {action}"}
+333
View File
@@ -127,6 +127,339 @@ TOOL_SCHEMAS: List[Dict[str, Any]] = [
}
}
},
{
"name": "world.nearby_entities",
"description": (
"Scan for entities near a player within a given radius. "
"Returns a list of entity types, counts, and distances. "
"Use this before kill/target commands to know what's around the player."
),
"parameters": {
"type": "object",
"properties": {
"player": {
"type": "string",
"description": "The player to scan around (case-sensitive)."
},
"radius": {
"type": "integer",
"description": "Search radius in blocks (default 32, max 128)."
}
},
"required": ["player"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {"type": "string"},
"count": {"type": "integer"},
"nearest_distance": {"type": "number"}
}
}
},
"total": {"type": "integer"}
}
}
},
{
"name": "memory.read",
"description": (
"Read saved memories for a player — locations, preferences, and facts. "
"Use this when a player references a saved location (e.g. 'tp me home') "
"or asks what you know about them."
),
"parameters": {
"type": "object",
"properties": {
"player": {
"type": "string",
"description": "Player whose memories to read (omit for requesting player)."
},
"key": {
"type": "string",
"description": "Specific memory key to look up (e.g. 'home', 'base'). Omit to get all."
}
},
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"memories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"type": {"type": "string"},
"value": {}
}
}
}
}
}
},
{
"name": "memory.write",
"description": (
"Save a memory for a player — a named location, preference, or fact. "
"Use this when a player asks you to remember something."
),
"parameters": {
"type": "object",
"properties": {
"player": {
"type": "string",
"description": "Player who owns this memory."
},
"type": {
"type": "string",
"enum": ["location", "preference", "fact"],
"description": "Memory type."
},
"key": {
"type": "string",
"description": "Memory name (e.g. 'home', 'base', 'favorite_tool')."
},
"value": {
"description": "Memory data — {x,y,z} for locations, string for others."
}
},
"required": ["player", "type", "key", "value"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"key": {"type": "string"}
}
}
},
# ── Script environment tools ──────────────────────────────────────────
{
"name": "script.write",
"description": (
"Write a mcfunction script to the Mortdecai datapack. Each line is one "
"Minecraft command (no leading slash). Use relative coordinates (~) for "
"position-relative builds. Scripts are stored at "
"mortdecai:functions/<name>.mcfunction. Overwrites if the script already exists."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Script name (e.g. 'build_house', 'arena_setup'). Lowercase, underscores."
},
"commands": {
"type": "array",
"items": {"type": "string"},
"description": "List of Minecraft commands, one per line. No leading slash."
},
"description": {
"type": "string",
"description": "Comment at the top of the file describing what this script does."
}
},
"required": ["name", "commands"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"path": {"type": "string"},
"lines": {"type": "integer"}
}
}
},
{
"name": "script.validate",
"description": (
"Validate a list of commands without writing or executing. Dry-runs each "
"command through RCON and reports errors. Use this before script.write to "
"catch mistakes. Returns per-line pass/fail results."
),
"parameters": {
"type": "object",
"properties": {
"commands": {
"type": "array",
"items": {"type": "string"},
"description": "List of Minecraft commands to validate."
}
},
"required": ["commands"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"valid": {"type": "boolean"},
"total": {"type": "integer"},
"passed": {"type": "integer"},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"line": {"type": "integer"},
"command": {"type": "string"},
"error": {"type": "string"}
}
}
}
}
}
},
{
"name": "script.execute",
"description": (
"Execute a previously written mcfunction script. Optionally run it as/at "
"a specific player for correct relative coordinates."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Script name to execute (e.g. 'build_house')."
},
"as_player": {
"type": "string",
"description": "Run the script as this player (for ~ coordinates). Optional."
}
},
"required": ["name"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"result": {"type": "string"}
}
}
},
{
"name": "script.read",
"description": (
"Read the contents of an existing mcfunction script. "
"Use this to review or debug a script before modifying it."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Script name to read."
}
},
"required": ["name"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"commands": {
"type": "array",
"items": {"type": "string"}
},
"lines": {"type": "integer"}
}
}
},
{
"name": "script.list",
"description": (
"List all scripts in the Mortdecai datapack. Returns script names, "
"line counts, and whether they are scheduled (tick/load)."
),
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"scripts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"lines": {"type": "integer"},
"scheduled": {"type": "string", "description": "tick, load, or none"}
}
}
}
}
}
},
{
"name": "script.delete",
"description": (
"Delete a mcfunction script from the datapack. Also removes it from "
"tick/load schedules if applicable."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Script name to delete."
}
},
"required": ["name"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"}
}
}
},
{
"name": "script.schedule",
"description": (
"Schedule a script to run automatically on tick (every game tick = 50ms) "
"or on load (when server starts/reloads). Use tick for continuous effects, "
"load for initialization. WARNING: tick functions run 20x/second — keep them "
"lightweight or they will lag the server."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Script name to schedule."
},
"type": {
"type": "string",
"enum": ["tick", "load"],
"description": "Schedule type."
}
},
"required": ["name", "type"],
"additionalProperties": False
},
"returns": {
"type": "object",
"properties": {
"ok": {"type": "boolean"}
}
}
},
]