Add teleport border guard, dimension-aware TP, fully enchanted context, enchantment guidance for god
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# LangGraph Implementation Idea
|
||||
|
||||
## Goal
|
||||
|
||||
Add a session-based agent gateway in front of Ollama so the Minecraft AI system can:
|
||||
- keep per-player/session memory,
|
||||
- run multi-step tool calls (MCP/web search),
|
||||
- return a final `message + commands` payload to the existing plugin.
|
||||
|
||||
This is a future enhancement. Current system is working and remains the source of truth for command execution safety.
|
||||
|
||||
---
|
||||
|
||||
## Why This Exists
|
||||
|
||||
Current `mc_aigod.py` calls Ollama directly. That is effectively stateless unless full history is re-sent every call.
|
||||
|
||||
A LangGraph sidecar can provide:
|
||||
- persistent sessions/threads,
|
||||
- tool loop orchestration,
|
||||
- model routing,
|
||||
- better observability of intermediate reasoning steps.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```text
|
||||
Minecraft chat -> mc_aigod.py -> LangGraph Gateway API -> Ollama
|
||||
| \
|
||||
| -> MCP tools (web search/wiki/etc)
|
||||
-> Session store (SQLite/Redis)
|
||||
```
|
||||
|
||||
`mc_aigod.py` remains responsible for:
|
||||
- trigger parsing (`pray`, `bible`, `sudo`),
|
||||
- RCON command execution,
|
||||
- whitelist/validation/fixups,
|
||||
- hard safety rules (e.g. first-login kill limits).
|
||||
|
||||
LangGraph gateway is responsible for:
|
||||
- session state,
|
||||
- tool calls,
|
||||
- composing final JSON output.
|
||||
|
||||
---
|
||||
|
||||
## API Contract Sketch
|
||||
|
||||
### Start session
|
||||
`POST /v1/session/start`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"player": "slingshooter08",
|
||||
"mode": "god"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Send message
|
||||
`POST /v1/session/{session_id}/message`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"role": "user",
|
||||
"text": "pray I need wood for shelter",
|
||||
"context": {
|
||||
"server_state": {},
|
||||
"player_state": {},
|
||||
"recent_events": []
|
||||
},
|
||||
"allow_tools": true,
|
||||
"max_tool_steps": 4
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"message": "Divine response text",
|
||||
"commands": [
|
||||
"give slingshooter08 minecraft:oak_log 64"
|
||||
],
|
||||
"tool_trace": [
|
||||
{
|
||||
"tool": "web.search",
|
||||
"input": "minecraft oak log item id",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### End session (optional)
|
||||
`POST /v1/session/{session_id}/close`
|
||||
|
||||
---
|
||||
|
||||
## LangGraph Flow (Draft)
|
||||
|
||||
1. Load session state by `session_id`
|
||||
2. Add user message + context
|
||||
3. Call command model
|
||||
4. If tool requested:
|
||||
- execute MCP tool
|
||||
- append tool result
|
||||
- loop until final commands or step limit reached
|
||||
5. Call message model with chosen commands
|
||||
6. Return final `{message, commands}`
|
||||
7. Persist updated session state
|
||||
|
||||
---
|
||||
|
||||
## Tooling Plan
|
||||
|
||||
Primary tools to add first:
|
||||
- `web.search` (general search)
|
||||
- `minecraft.wiki_lookup` (targeted page fetch/search)
|
||||
|
||||
Potential later tools:
|
||||
- local documentation lookup,
|
||||
- server analytics lookup,
|
||||
- schematic index lookup.
|
||||
|
||||
---
|
||||
|
||||
## Modes to Support
|
||||
|
||||
- `god` (prayer flow, tool-enabled)
|
||||
- `sudo` (translator flow, likely tool-disabled or very limited)
|
||||
- `god_system` (intervention/first-login internal events)
|
||||
|
||||
---
|
||||
|
||||
## Safety Model (Keep in mc_aigod.py)
|
||||
|
||||
Even after LangGraph is added, keep hard enforcement in plugin runtime:
|
||||
- whitelist command families,
|
||||
- syntax repair + normalization,
|
||||
- max commands cap,
|
||||
- per-flow constraints (e.g. first-login benevolence restrictions),
|
||||
- unauthorized sudo user rejection.
|
||||
|
||||
This ensures model/tool errors cannot directly bypass execution safeguards.
|
||||
|
||||
---
|
||||
|
||||
## MVP Steps (Later)
|
||||
|
||||
1. Build FastAPI LangGraph gateway with in-memory sessions
|
||||
2. Add `/session/start` + `/session/{id}/message`
|
||||
3. Mirror current two-call behavior (no tools yet)
|
||||
4. Switch `mc_aigod.py` to gateway endpoint
|
||||
5. Add one MCP search tool and bounded tool loop
|
||||
6. Add persistence (SQLite/Redis)
|
||||
7. Add structured logs + tool traces
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Session lifetime policy (per player, per login, time-based expiry?)
|
||||
- Whether `sudo` should ever be tool-enabled
|
||||
- How much tool trace to expose in-game vs log-only
|
||||
- Which MCP stack to standardize on for web search
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
This document is a planning scratchpad for future implementation. It is intentionally practical and API-first so a coder bot can pick it up and implement directly.
|
||||
+183
-5
@@ -526,6 +526,14 @@ EFFECTS (replace {target} with any online player's username):
|
||||
|
||||
MOVEMENT:
|
||||
tp {target} 0 64 0
|
||||
tp {target} <x> <y> <z>
|
||||
tp {target} ~ ~10 ~
|
||||
execute in minecraft:the_nether run tp {target} <x> <y> <z>
|
||||
execute in minecraft:the_end run tp {target} 0 64 0
|
||||
execute in minecraft:overworld run tp {target} <x> <y> <z>
|
||||
NOTE: To teleport a player to another dimension ALWAYS use:
|
||||
execute in minecraft:<dimension> run tp <player> <x> <y> <z>
|
||||
NEVER use: tp <player> minecraft:the_nether (this is wrong syntax)
|
||||
|
||||
WORLD/ENVIRONMENT (affects all players):
|
||||
time set day
|
||||
@@ -668,6 +676,36 @@ def _parse_llm_json(content: str) -> dict:
|
||||
log.warning(f"Repaired JSON: message={len(message)}chars, commands={commands}")
|
||||
return result
|
||||
|
||||
ENCHANTMENT_CONTEXT = """
|
||||
=== ENCHANTMENT RULES FOR GOD ===
|
||||
When giving weapons, tools, or armor as a divine gift, you should ALMOST ALWAYS enchant them.
|
||||
Enchanted gifts feel more divine. Unenchanted items are acceptable only as a deliberate
|
||||
choice (e.g. giving basic materials, a punishment of mediocrity, or items that cannot be enchanted).
|
||||
|
||||
Use 1.21 component syntax: give <player> minecraft:<item>[enchantments={ench1:lvl,ench2:lvl}] 1
|
||||
|
||||
MAX ENCHANTMENT REFERENCE (use as baseline for "fully enchanted" or "blessed" gifts):
|
||||
|
||||
SWORD: netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}]
|
||||
PICKAXE: netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
|
||||
AXE: netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}]
|
||||
SHOVEL: netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
|
||||
HOE: netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}]
|
||||
BOW: bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}]
|
||||
CROSSBOW: crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}]
|
||||
TRIDENT: trident[enchantments={channeling:1,loyalty:3,unbreaking:3,mending:1,impaling:5}]
|
||||
HELMET: netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}]
|
||||
CHEST: netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}]
|
||||
LEGS: netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}]
|
||||
BOOTS: netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}]
|
||||
FISHING: fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}]
|
||||
ELYTRA: elytra[enchantments={unbreaking:3,mending:1}]
|
||||
SHIELD: shield[enchantments={unbreaking:3,mending:1}]
|
||||
|
||||
You do NOT need to always give max enchants — a modest reward may have fewer.
|
||||
But unenchanted weapons/tools/armor from God should be the exception, not the rule.
|
||||
"""
|
||||
|
||||
COMMANDS_SYSTEM_PROMPT = (
|
||||
"You are a Minecraft server command executor. Given a player's prayer and server context, "
|
||||
"decide what server commands to run (if any) as an act of God.\n\n"
|
||||
@@ -688,6 +726,7 @@ COMMANDS_SYSTEM_PROMPT = (
|
||||
+ COMMAND_PALETTE
|
||||
+ "\n=== ITEM LIBRARY ===\n"
|
||||
+ ITEM_LIBRARY
|
||||
+ ENCHANTMENT_CONTEXT
|
||||
)
|
||||
|
||||
SUDO_COMMANDS_SYSTEM_PROMPT = (
|
||||
@@ -700,9 +739,68 @@ SUDO_COMMANDS_SYSTEM_PROMPT = (
|
||||
"- If the request cannot be mapped safely, return commands: [].\n"
|
||||
"- If player says 'me' or 'my', target the requesting player.\n"
|
||||
"- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n"
|
||||
"- For give syntax: give <player> minecraft:<item_id> <count>\n"
|
||||
"- Count is last. Namespace minecraft: is required.\n"
|
||||
"- For give syntax: give <player> minecraft:<item_id> <count> (count LAST, namespace required)\n"
|
||||
"- Return commands only. No commentary.\n"
|
||||
"\n"
|
||||
"=== TELEPORT SYNTAX ===\n"
|
||||
"Same dimension: tp <player> <x> <y> <z>\n"
|
||||
"Relative: tp <player> ~ ~10 ~\n"
|
||||
"To Nether: execute in minecraft:the_nether run tp <player> <x> <y> <z>\n"
|
||||
"To End: execute in minecraft:the_end run tp <player> <x> 64 <z>\n"
|
||||
"To Overworld: execute in minecraft:overworld run tp <player> <x> <y> <z>\n"
|
||||
"WRONG (never do this): tp <player> minecraft:the_nether\n"
|
||||
"When dimension is unspecified, use a sensible default spawn coord for that dimension.\n"
|
||||
"\n"
|
||||
"=== FULLY ENCHANTED (max enchantments per item type, 1.21 syntax) ===\n"
|
||||
"Use item[enchantments={...}] syntax. Count is always 1 for enchanted items.\n"
|
||||
"\n"
|
||||
"SWORD (netherite_sword):\n"
|
||||
" give <p> minecraft:netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}] 1\n"
|
||||
"\n"
|
||||
"PICKAXE (netherite_pickaxe):\n"
|
||||
" give <p> minecraft:netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"AXE (netherite_axe):\n"
|
||||
" give <p> minecraft:netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}] 1\n"
|
||||
"\n"
|
||||
"SHOVEL (netherite_shovel):\n"
|
||||
" give <p> minecraft:netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"HOE (netherite_hoe):\n"
|
||||
" give <p> minecraft:netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"BOW:\n"
|
||||
" give <p> minecraft:bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}] 1\n"
|
||||
"\n"
|
||||
"CROSSBOW:\n"
|
||||
" give <p> minecraft:crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"TRIDENT:\n"
|
||||
" give <p> minecraft:trident[enchantments={channeling:1,loyalty:3,riptide:3,unbreaking:3,mending:1,impaling:5}] 1\n"
|
||||
" (note: riptide and loyalty/channeling are mutually exclusive — pick one set)\n"
|
||||
"\n"
|
||||
"HELMET (netherite_helmet):\n"
|
||||
" give <p> minecraft:netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"CHESTPLATE (netherite_chestplate):\n"
|
||||
" give <p> minecraft:netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"LEGGINGS (netherite_leggings):\n"
|
||||
" give <p> minecraft:netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"BOOTS (netherite_boots):\n"
|
||||
" give <p> minecraft:netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"FISHING ROD:\n"
|
||||
" give <p> minecraft:fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"ELYTRA:\n"
|
||||
" give <p> minecraft:elytra[enchantments={unbreaking:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"SHIELD:\n"
|
||||
" give <p> minecraft:shield[enchantments={unbreaking:3,mending:1}] 1\n"
|
||||
"\n"
|
||||
"When player asks for 'fully enchanted', 'max enchanted', 'best', 'godlike' gear — use the above templates.\n"
|
||||
)
|
||||
|
||||
FIRST_LOGIN_BENEVOLENCE_PROMPT = (
|
||||
@@ -921,6 +1019,83 @@ SAFE_PREFIXES = [
|
||||
'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ',
|
||||
]
|
||||
|
||||
_border_cache = {"ts": 0.0, "half": None}
|
||||
|
||||
|
||||
def _tp_border_guard_enabled(config) -> bool:
|
||||
return bool(config.get("tp_border_guard_enabled", True))
|
||||
|
||||
|
||||
def _parse_abs_coord(tok: str):
|
||||
tok = (tok or "").strip()
|
||||
if not tok or tok.startswith("~") or tok.startswith("^"):
|
||||
return None
|
||||
try:
|
||||
return float(tok)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tp_abs_xyz(cmd: str):
|
||||
for m in re.finditer(r'\btp\s+\S+\s+(\S+)\s+(\S+)\s+(\S+)', cmd):
|
||||
x = _parse_abs_coord(m.group(1))
|
||||
y = _parse_abs_coord(m.group(2))
|
||||
z = _parse_abs_coord(m.group(3))
|
||||
if x is None or y is None or z is None:
|
||||
continue
|
||||
return x, y, z
|
||||
return None
|
||||
|
||||
|
||||
def _worldborder_half_extent(config):
|
||||
now = time.time()
|
||||
if now - float(_border_cache.get("ts", 0.0)) < 10.0 and _border_cache.get("half") is not None:
|
||||
return float(_border_cache["half"])
|
||||
|
||||
try:
|
||||
raw = rcon("worldborder get", config["rcon_host"], config["rcon_port"], config["rcon_password"])
|
||||
nums = re.findall(r'(-?[\d.]+)', raw or "")
|
||||
if not nums:
|
||||
return None
|
||||
width = float(nums[0])
|
||||
half = max(0.0, width / 2.0)
|
||||
_border_cache["ts"] = now
|
||||
_border_cache["half"] = half
|
||||
return half
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_OTHER_DIMENSION_RE = re.compile(
|
||||
r'\bexecute\s+in\s+minecraft:(the_nether|the_end|nether|end)\b', re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _tp_inside_worldborder(cmd: str, config) -> bool:
|
||||
if not _tp_border_guard_enabled(config):
|
||||
return True
|
||||
|
||||
# Nether/End dimension teleports use different coordinate spaces — skip border check.
|
||||
if _OTHER_DIMENSION_RE.search(cmd):
|
||||
return True
|
||||
|
||||
xyz = _extract_tp_abs_xyz(cmd)
|
||||
if not xyz:
|
||||
# Relative/dynamic TP can't be statically checked here.
|
||||
return True
|
||||
|
||||
half = _worldborder_half_extent(config)
|
||||
if half is None:
|
||||
return True
|
||||
|
||||
x, _, z = xyz
|
||||
cx = float(config.get("worldborder_center_x", 0.0))
|
||||
cz = float(config.get("worldborder_center_z", 0.0))
|
||||
margin = max(0.0, float(config.get("tp_border_margin", 2.0)))
|
||||
limit = max(0.0, half - margin)
|
||||
|
||||
return abs(x - cx) <= limit and abs(z - cz) <= limit
|
||||
|
||||
def fix_give_command(cmd: str) -> str:
|
||||
"""
|
||||
Correct common LLM give command mistakes:
|
||||
@@ -985,7 +1160,7 @@ def fix_effect_command(cmd: str) -> str:
|
||||
return fixed
|
||||
return cmd
|
||||
|
||||
def validate_command(cmd, online_players, fallback_player):
|
||||
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)
|
||||
resolved = fix_give_command(resolved)
|
||||
@@ -993,6 +1168,9 @@ def validate_command(cmd, online_players, fallback_player):
|
||||
if not any(resolved.startswith(p) for p in SAFE_PREFIXES):
|
||||
log.warning(f"Command blocked (unknown prefix): {resolved}")
|
||||
return resolved, False
|
||||
if config and _extract_tp_abs_xyz(resolved) and not _tp_inside_worldborder(resolved, config):
|
||||
log.warning(f"Command blocked (tp outside worldborder): {resolved}")
|
||||
return resolved, False
|
||||
return resolved, True
|
||||
|
||||
def execute_response(response, context, config, praying_player=None):
|
||||
@@ -1050,7 +1228,7 @@ def execute_response(response, context, config, praying_player=None):
|
||||
)
|
||||
|
||||
for cmd in commands[:max_cmds]:
|
||||
resolved, is_safe = validate_command(cmd, context["online_players"], fallback)
|
||||
resolved, is_safe = validate_command(cmd, context["online_players"], fallback, config)
|
||||
if not is_safe:
|
||||
continue
|
||||
log.info(f"Executing RCON: {resolved}")
|
||||
@@ -1242,7 +1420,7 @@ def process_sudo(player, prompt, config):
|
||||
|
||||
executed = []
|
||||
for cmd in commands:
|
||||
resolved, is_safe = validate_command(cmd, online, player)
|
||||
resolved, is_safe = validate_command(cmd, online, player, config)
|
||||
if not is_safe:
|
||||
continue
|
||||
log.info(f"SUDO execute: {resolved}")
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"sudo_enabled": true,
|
||||
"sudo_user": "slingshooter08",
|
||||
"sudo_max_commands": 3,
|
||||
"tp_border_guard_enabled": true,
|
||||
"worldborder_center_x": 0,
|
||||
"worldborder_center_z": 0,
|
||||
"tp_border_margin": 2,
|
||||
"first_login_benevolence_enabled": true,
|
||||
"first_login_benevolence_max_commands": 10,
|
||||
"first_login_path": "/opt/mcsmanager/daemon/data/InstanceData/shrinkborder1234567890abcdef12345/aigod_first_login_seen.json",
|
||||
|
||||
Reference in New Issue
Block a user