Add LangGraph gateway, template manager, teleport border guard, enchantment context, server_type prep, session persistence
This commit is contained in:
@@ -0,0 +1,140 @@
|
|||||||
|
# LangGraph Gateway - Implemented MVP (Paper Fork)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented in this fork as a FastAPI sidecar:
|
||||||
|
|
||||||
|
- Script: `/usr/local/bin/langgraph_gateway.py`
|
||||||
|
- Service: `mc-langgraph-gateway.service`
|
||||||
|
- Config: `/etc/mc_langgraph_gateway.json`
|
||||||
|
- Bind: `127.0.0.1:8091`
|
||||||
|
|
||||||
|
`mc_aigod_paper.py` can route pray/sudo/system flows through this gateway when:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"use_langgraph_gateway": true
|
||||||
|
```
|
||||||
|
|
||||||
|
Safety enforcement remains in `mc_aigod_paper.py` (whitelist, fixups, caps, auth checks).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented API
|
||||||
|
|
||||||
|
### Start session
|
||||||
|
`POST /v1/session/start`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "slingshooter08",
|
||||||
|
"mode": "god"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "sess_xxxxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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": "minecraft.wiki_lookup", "ok": true, "results_count": 2}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Close session
|
||||||
|
`POST /v1/session/{session_id}/close`
|
||||||
|
|
||||||
|
### Health
|
||||||
|
`GET /healthz`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session + Memory Model
|
||||||
|
|
||||||
|
- In-memory sessions keyed by `session_id`
|
||||||
|
- `mc_aigod_paper.py` keeps player+mode -> session_id mapping
|
||||||
|
- `/v1/session/start` reuses active session for same player+mode when possible
|
||||||
|
- Session TTL configurable (`session_ttl_seconds`, default 6h)
|
||||||
|
- Last message turns are fed back into gateway model calls
|
||||||
|
- SQLite persistence enabled by default via `session_db_path` (survives gateway restarts)
|
||||||
|
|
||||||
|
Redis backend is not implemented yet (optional future step).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modes Wired
|
||||||
|
|
||||||
|
- `god` -> prayer flow
|
||||||
|
- `sudo` -> translator flow
|
||||||
|
- `god_system` -> interventions and first-login system events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Loop (MVP)
|
||||||
|
|
||||||
|
Bounded tool loop implemented with max step cap:
|
||||||
|
|
||||||
|
- `minecraft.wiki_lookup`
|
||||||
|
- `web.search`
|
||||||
|
|
||||||
|
Current planner is lightweight heuristic router; this is intentionally bounded and conservative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime Safeguards (still in mc_aigod_paper.py)
|
||||||
|
|
||||||
|
Even with gateway enabled, commands are still post-processed by runtime safety:
|
||||||
|
|
||||||
|
- command family whitelist
|
||||||
|
- syntax repair + normalization
|
||||||
|
- max commands cap
|
||||||
|
- sudo user authorization
|
||||||
|
- first-login constraints (e.g. max one player kill)
|
||||||
|
|
||||||
|
So gateway/model/tool errors cannot directly bypass execution constraints.
|
||||||
|
|
||||||
|
Gateway also applies an early command sanitation pass before returning output:
|
||||||
|
|
||||||
|
- strips leading `/`
|
||||||
|
- drops prose/non-command payloads
|
||||||
|
- enforces mode-specific command prefixes
|
||||||
|
- deduplicates and caps command count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Current Behavior
|
||||||
|
|
||||||
|
- Session state survives gateway restarts when SQLite persistence is enabled.
|
||||||
|
- Tool traces are logged in `mc_aigod_paper.log` but not shown in-game.
|
||||||
|
- Redis persistence backend is not implemented yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Add optional Redis persistence backend
|
||||||
|
2. Add richer tool planner node (LangGraph proper state machine)
|
||||||
|
3. Add optional tool trace exposure in debug mode
|
||||||
|
4. Add stricter command schema output in gateway (model-side)
|
||||||
|
5. Add MCP-based tool adapters
|
||||||
@@ -25,6 +25,35 @@ This fork targets a dedicated **Paper** server on port `25567` and adds advanced
|
|||||||
- Message model: `gemma3:12b`
|
- Message model: `gemma3:12b`
|
||||||
- Command model: `qwen3-coder:30b`
|
- Command model: `qwen3-coder:30b`
|
||||||
|
|
||||||
|
### LangGraph-style Gateway Sidecar (implemented)
|
||||||
|
|
||||||
|
- Service: `mc-langgraph-gateway.service`
|
||||||
|
- API: `http://127.0.0.1:8091`
|
||||||
|
- Script: `/usr/local/bin/langgraph_gateway.py`
|
||||||
|
- Config: `/etc/mc_langgraph_gateway.json`
|
||||||
|
|
||||||
|
`mc_aigod_paper.py` can route modes through session APIs:
|
||||||
|
|
||||||
|
- `god` (prayer)
|
||||||
|
- `sudo` (translator)
|
||||||
|
- `god_system` (intervention / first-login)
|
||||||
|
|
||||||
|
Enable via config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"use_langgraph_gateway": true,
|
||||||
|
"langgraph_gateway_url": "http://127.0.0.1:8091"
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime still enforces whitelist/repair/caps/auth after gateway output.
|
||||||
|
|
||||||
|
Gateway hardening currently included:
|
||||||
|
|
||||||
|
- Session reuse by `player+mode` when still active (less duplicate session churn)
|
||||||
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
@@ -60,6 +89,32 @@ Examples:
|
|||||||
- `sudo create church`
|
- `sudo create church`
|
||||||
- `sudo build wall`
|
- `sudo build wall`
|
||||||
|
|
||||||
|
Template manager commands:
|
||||||
|
|
||||||
|
- `sudo template search <query>`
|
||||||
|
- `sudo template download <https-url|#n|n> [name]`
|
||||||
|
- `sudo template install <https-url> [name]`
|
||||||
|
- `sudo template pick <n> [name]` (download from last search result index)
|
||||||
|
- `sudo template sync` (pull from configured sync sources/manifest)
|
||||||
|
- `sudo template build <filename|name>` (or no arg = last downloaded template)
|
||||||
|
- `sudo template list`
|
||||||
|
- `sudo template delete <filename>`
|
||||||
|
- `sudo template hosts`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Template download now requires direct file URLs only (`.schem/.schematic/.nbt/.zip`)
|
||||||
|
- Template search is direct-link only (no page scraping)
|
||||||
|
- Recommended workflow is `template sync` + `template build`
|
||||||
|
|
||||||
|
Info lookup mode via sudo:
|
||||||
|
|
||||||
|
- `sudo lookup <question>`
|
||||||
|
- `sudo wiki <question>`
|
||||||
|
- `sudo search <question>`
|
||||||
|
|
||||||
|
Lookup mode is information-only (wiki/web retrieval + optional justification), and does not execute game commands.
|
||||||
|
|
||||||
These trigger multi-command `fill/setblock/give` sequences near the player and are optimized for Paper performance.
|
These trigger multi-command `fill/setblock/give` sequences near the player and are optimized for Paper performance.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
langgraph_gateway.py
|
||||||
|
|
||||||
|
Session-based LLM gateway sidecar for Minecraft AI.
|
||||||
|
Provides:
|
||||||
|
- per-player sessions
|
||||||
|
- bounded tool loop (web.search, minecraft.wiki_lookup)
|
||||||
|
- final {message, commands, tool_trace} payload
|
||||||
|
|
||||||
|
This is intentionally lightweight and API-first.
|
||||||
|
Execution safety remains in mc_aigod_paper.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [gateway] %(levelname)s: %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler('/var/log/mc_langgraph_gateway.log'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_PATH = '/etc/mc_langgraph_gateway.json'
|
||||||
|
|
||||||
|
|
||||||
|
class StartSessionRequest(BaseModel):
|
||||||
|
player: str
|
||||||
|
mode: str = Field(pattern='^(god|sudo|god_system)$')
|
||||||
|
|
||||||
|
|
||||||
|
class StartSessionResponse(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRequest(BaseModel):
|
||||||
|
role: str = Field(default='user')
|
||||||
|
text: str
|
||||||
|
context: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
allow_tools: bool = True
|
||||||
|
max_tool_steps: int = 4
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
message: Optional[str] = None
|
||||||
|
commands: List[str] = Field(default_factory=list)
|
||||||
|
tool_trace: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionState:
|
||||||
|
session_id: str
|
||||||
|
player: str
|
||||||
|
mode: str
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
updated_at: float = field(default_factory=time.time)
|
||||||
|
messages: List[Dict[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
_sessions: Dict[str, SessionState] = {}
|
||||||
|
_sessions_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
COMMAND_PREFIXES_BY_MODE = {
|
||||||
|
'sudo': [
|
||||||
|
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ',
|
||||||
|
'kill ', 'summon ', 'tellraw ', 'worldborder ', 'fill ', 'setblock ',
|
||||||
|
'clone ',
|
||||||
|
],
|
||||||
|
'god': [
|
||||||
|
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ',
|
||||||
|
'kill ', 'summon ', 'tellraw ', 'worldborder ',
|
||||||
|
],
|
||||||
|
'god_system': [
|
||||||
|
'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ',
|
||||||
|
'kill ', 'summon ', 'tellraw ', 'worldborder ',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.warning('Config not found, using defaults')
|
||||||
|
return {
|
||||||
|
'ollama_url': 'http://127.0.0.1:11434',
|
||||||
|
'message_model': 'gemma3:12b',
|
||||||
|
'command_model': 'qwen3-coder:30b',
|
||||||
|
'tool_model': 'qwen2.5:1.5b',
|
||||||
|
'session_ttl_seconds': 21600,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CFG = load_config()
|
||||||
|
DB_PATH = CFG.get('session_db_path', '/var/lib/mc-langgraph-gateway/sessions.db')
|
||||||
|
_db_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_enabled() -> bool:
|
||||||
|
return bool(CFG.get('session_persistence_enabled', True))
|
||||||
|
|
||||||
|
|
||||||
|
def _db_conn() -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_init():
|
||||||
|
if not _db_enabled():
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
'CREATE TABLE IF NOT EXISTS sessions ('
|
||||||
|
'session_id TEXT PRIMARY KEY,'
|
||||||
|
'player TEXT NOT NULL,'
|
||||||
|
'mode TEXT NOT NULL,'
|
||||||
|
'created_at REAL NOT NULL,'
|
||||||
|
'updated_at REAL NOT NULL,'
|
||||||
|
'messages_json TEXT NOT NULL'
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_player_mode ON sessions(player, mode)')
|
||||||
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)')
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _session_to_row(s: SessionState):
|
||||||
|
return (
|
||||||
|
s.session_id,
|
||||||
|
s.player,
|
||||||
|
s.mode,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at,
|
||||||
|
json.dumps(s.messages[-60:], ensure_ascii=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_session(row) -> SessionState:
|
||||||
|
messages: List[Dict[str, str]]
|
||||||
|
try:
|
||||||
|
messages = json.loads(row[5])
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
messages = []
|
||||||
|
except Exception:
|
||||||
|
messages = []
|
||||||
|
return SessionState(
|
||||||
|
session_id=row[0],
|
||||||
|
player=row[1],
|
||||||
|
mode=row[2],
|
||||||
|
created_at=float(row[3]),
|
||||||
|
updated_at=float(row[4]),
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_upsert_session(s: SessionState):
|
||||||
|
if not _db_enabled():
|
||||||
|
return
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO sessions (session_id, player, mode, created_at, updated_at, messages_json) '
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?) '
|
||||||
|
'ON CONFLICT(session_id) DO UPDATE SET '
|
||||||
|
'player=excluded.player, mode=excluded.mode, created_at=excluded.created_at, '
|
||||||
|
'updated_at=excluded.updated_at, messages_json=excluded.messages_json',
|
||||||
|
_session_to_row(s),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_get_by_id(session_id: str) -> Optional[SessionState]:
|
||||||
|
if not _db_enabled():
|
||||||
|
return None
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
'SELECT session_id, player, mode, created_at, updated_at, messages_json '
|
||||||
|
'FROM sessions WHERE session_id = ? LIMIT 1',
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_session(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_get_by_player_mode(player: str, mode: str) -> Optional[SessionState]:
|
||||||
|
if not _db_enabled():
|
||||||
|
return None
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
'SELECT session_id, player, mode, created_at, updated_at, messages_json '
|
||||||
|
'FROM sessions WHERE player = ? AND mode = ? ORDER BY updated_at DESC LIMIT 1',
|
||||||
|
(player, mode),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_session(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_delete(session_id: str):
|
||||||
|
if not _db_enabled():
|
||||||
|
return
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
conn.execute('DELETE FROM sessions WHERE session_id = ?', (session_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_cleanup_expired(ttl_seconds: int) -> int:
|
||||||
|
if not _db_enabled():
|
||||||
|
return 0
|
||||||
|
cutoff = time.time() - ttl_seconds
|
||||||
|
with _db_lock:
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute('DELETE FROM sessions WHERE updated_at < ?', (cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.rowcount or 0)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_sessions():
|
||||||
|
ttl = int(CFG.get('session_ttl_seconds', 21600))
|
||||||
|
now = time.time()
|
||||||
|
with _sessions_lock:
|
||||||
|
dead = [sid for sid, s in _sessions.items() if now - s.updated_at > ttl]
|
||||||
|
for sid in dead:
|
||||||
|
_sessions.pop(sid, None)
|
||||||
|
if dead:
|
||||||
|
log.info('Cleaned %d expired sessions', len(dead))
|
||||||
|
db_dead = _db_cleanup_expired(ttl)
|
||||||
|
if db_dead:
|
||||||
|
log.info('Cleaned %d expired DB sessions', db_dead)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_get(session_id: str) -> SessionState:
|
||||||
|
_cleanup_sessions()
|
||||||
|
with _sessions_lock:
|
||||||
|
s = _sessions.get(session_id)
|
||||||
|
if not s:
|
||||||
|
s = _db_get_by_id(session_id)
|
||||||
|
if s:
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions[session_id] = s
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(status_code=404, detail='session not found')
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _session_create(player: str, mode: str) -> SessionState:
|
||||||
|
_cleanup_sessions()
|
||||||
|
with _sessions_lock:
|
||||||
|
for existing in _sessions.values():
|
||||||
|
if existing.player == player and existing.mode == mode:
|
||||||
|
existing.updated_at = time.time()
|
||||||
|
_db_upsert_session(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
persisted = _db_get_by_player_mode(player, mode)
|
||||||
|
if persisted:
|
||||||
|
persisted.updated_at = time.time()
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions[persisted.session_id] = persisted
|
||||||
|
_db_upsert_session(persisted)
|
||||||
|
return persisted
|
||||||
|
|
||||||
|
sid = 'sess_' + uuid.uuid4().hex[:16]
|
||||||
|
s = SessionState(session_id=sid, player=player, mode=mode)
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions[sid] = s
|
||||||
|
_db_upsert_session(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_chat(model: str, messages: List[Dict[str, str]], *, fmt: Optional[str] = None,
|
||||||
|
temperature: float = 0.7, max_tokens: int = 400, timeout: int = 60) -> str:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
'model': model,
|
||||||
|
'messages': messages,
|
||||||
|
'stream': False,
|
||||||
|
'options': {
|
||||||
|
'temperature': temperature,
|
||||||
|
'num_predict': max_tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmt:
|
||||||
|
payload['format'] = fmt
|
||||||
|
|
||||||
|
r = requests.post(f"{CFG['ollama_url']}/api/chat", json=payload, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()['message']['content']
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(content: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# salvage commands if partially valid
|
||||||
|
cmds = []
|
||||||
|
m = re_search_commands(content)
|
||||||
|
if m:
|
||||||
|
cmds = m
|
||||||
|
return {'commands': cmds, 'message': ''}
|
||||||
|
|
||||||
|
|
||||||
|
def re_search_commands(content: str) -> List[str]:
|
||||||
|
import re
|
||||||
|
m = re.search(r'"commands"\s*:\s*\[(.*?)(?:\]|$)', content, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
return re.findall(r'"([^"]+)"', m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def tool_web_search(query: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
r = requests.get('https://api.duckduckgo.com/', params={
|
||||||
|
'q': query,
|
||||||
|
'format': 'json',
|
||||||
|
'no_redirect': 1,
|
||||||
|
'no_html': 1,
|
||||||
|
}, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
out = []
|
||||||
|
if data.get('AbstractText'):
|
||||||
|
out.append({'title': 'Abstract', 'text': data['AbstractText']})
|
||||||
|
for item in (data.get('RelatedTopics') or [])[:3]:
|
||||||
|
if isinstance(item, dict) and item.get('Text'):
|
||||||
|
out.append({'title': item.get('FirstURL', ''), 'text': item['Text']})
|
||||||
|
return {'ok': True, 'results': out}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_wiki_lookup(query: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
s = requests.get('https://minecraft.wiki/api.php', params={
|
||||||
|
'action': 'opensearch',
|
||||||
|
'format': 'json',
|
||||||
|
'search': query,
|
||||||
|
'limit': 3,
|
||||||
|
}, timeout=20)
|
||||||
|
s.raise_for_status()
|
||||||
|
data = s.json()
|
||||||
|
titles = data[1] if len(data) > 1 else []
|
||||||
|
if not titles:
|
||||||
|
return {'ok': True, 'results': []}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for t in titles:
|
||||||
|
e = requests.get('https://minecraft.wiki/api.php', params={
|
||||||
|
'action': 'query',
|
||||||
|
'format': 'json',
|
||||||
|
'prop': 'extracts',
|
||||||
|
'exintro': 1,
|
||||||
|
'explaintext': 1,
|
||||||
|
'titles': t,
|
||||||
|
}, timeout=20)
|
||||||
|
e.raise_for_status()
|
||||||
|
pages = e.json().get('query', {}).get('pages', {})
|
||||||
|
extract = ''
|
||||||
|
for p in pages.values():
|
||||||
|
extract = (p.get('extract') or '')[:500]
|
||||||
|
break
|
||||||
|
results.append({'title': t, 'text': extract})
|
||||||
|
return {'ok': True, 'results': results}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_router(user_text: str, max_steps: int) -> 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 any(k in text for k in ['wiki', 'minecraft', 'item id', 'recipe', 'craft']):
|
||||||
|
calls.append({'tool': 'minecraft.wiki_lookup', 'query': user_text})
|
||||||
|
|
||||||
|
if len(calls) < max_steps and any(k in text for k in ['what is', 'how to', 'search', 'lookup']):
|
||||||
|
calls.append({'tool': 'web.search', 'query': user_text})
|
||||||
|
|
||||||
|
return calls[:max_steps]
|
||||||
|
|
||||||
|
|
||||||
|
def _commands_prompt(mode: str) -> str:
|
||||||
|
allowed = ','.join(
|
||||||
|
p.strip() for p in COMMAND_PREFIXES_BY_MODE.get(mode, COMMAND_PREFIXES_BY_MODE['god'])
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == 'sudo':
|
||||||
|
return (
|
||||||
|
'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.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == 'god_system':
|
||||||
|
return (
|
||||||
|
'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'
|
||||||
|
'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.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'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'
|
||||||
|
'Balance benevolence and judgment based on context.\n'
|
||||||
|
'Use valid Minecraft command syntax only.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_prompt(mode: str) -> str:
|
||||||
|
if mode == 'sudo':
|
||||||
|
return 'Return empty string.'
|
||||||
|
if mode == 'god_system':
|
||||||
|
return (
|
||||||
|
'You are God speaking to all players about a system event. '
|
||||||
|
'Return plain text only, no JSON.'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'You are God in Minecraft. Return a dramatic but clear message as plain text only.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_commands(commands_raw: Any, mode: str) -> List[str]:
|
||||||
|
allowed_prefixes = COMMAND_PREFIXES_BY_MODE.get(mode, COMMAND_PREFIXES_BY_MODE['god'])
|
||||||
|
max_commands = int(CFG.get('gateway_max_commands', 8))
|
||||||
|
cleaned: List[str] = []
|
||||||
|
|
||||||
|
if not isinstance(commands_raw, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
for entry in commands_raw:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pieces = re.split(r'[\n;]+', entry)
|
||||||
|
for piece in pieces:
|
||||||
|
cmd = piece.strip()
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.startswith('/'):
|
||||||
|
cmd = cmd[1:].strip()
|
||||||
|
|
||||||
|
cmd = cmd.rstrip(' .')
|
||||||
|
if len(cmd) < 3 or len(cmd) > 240:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.lower().startswith('commands'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not any(cmd.startswith(p) for p in allowed_prefixes):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned.append(cmd)
|
||||||
|
if len(cleaned) >= max_commands:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(cleaned) >= max_commands:
|
||||||
|
break
|
||||||
|
|
||||||
|
deduped: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for cmd in cleaned:
|
||||||
|
if cmd in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cmd)
|
||||||
|
deduped.append(cmd)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
||||||
|
session.updated_at = time.time()
|
||||||
|
|
||||||
|
# Compose input text
|
||||||
|
context_json = json.dumps(req.context or {}, ensure_ascii=True)
|
||||||
|
user_text = req.text.strip()
|
||||||
|
user_blob = f"message: {user_text}\ncontext: {context_json}"
|
||||||
|
|
||||||
|
session.messages.append({'role': req.role, 'content': user_blob})
|
||||||
|
_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)))
|
||||||
|
for c in calls:
|
||||||
|
tool = c['tool']
|
||||||
|
q = c['query']
|
||||||
|
if tool == 'web.search':
|
||||||
|
out = tool_web_search(q)
|
||||||
|
elif tool == 'minecraft.wiki_lookup':
|
||||||
|
out = tool_wiki_lookup(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"
|
||||||
|
|
||||||
|
# Commands call
|
||||||
|
cmd_messages = [
|
||||||
|
{'role': 'system', 'content': _commands_prompt(session.mode)},
|
||||||
|
*session.messages[-12:],
|
||||||
|
{'role': 'user', 'content': user_blob + tool_results_block},
|
||||||
|
]
|
||||||
|
cmd_raw = _ollama_chat(
|
||||||
|
CFG.get('command_model', 'qwen3-coder:30b'),
|
||||||
|
cmd_messages,
|
||||||
|
fmt='json',
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=220,
|
||||||
|
)
|
||||||
|
cmd_parsed = _parse_json(cmd_raw)
|
||||||
|
commands = _sanitize_commands(cmd_parsed.get('commands') or [], session.mode)
|
||||||
|
|
||||||
|
# Message call (not for sudo)
|
||||||
|
message = None
|
||||||
|
if session.mode != 'sudo':
|
||||||
|
msg_messages = [
|
||||||
|
{'role': 'system', 'content': _message_prompt(session.mode)},
|
||||||
|
*session.messages[-12:],
|
||||||
|
{'role': 'user', 'content': user_blob + f"\nChosen commands: {commands}" + tool_results_block},
|
||||||
|
]
|
||||||
|
message = _ollama_chat(
|
||||||
|
CFG.get('message_model', 'gemma3:12b'),
|
||||||
|
msg_messages,
|
||||||
|
fmt=None,
|
||||||
|
temperature=0.8,
|
||||||
|
max_tokens=500,
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# Save assistant summary back to session memory
|
||||||
|
session.messages.append({
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': json.dumps({'message': message, 'commands': commands}, ensure_ascii=True)
|
||||||
|
})
|
||||||
|
_db_upsert_session(session)
|
||||||
|
|
||||||
|
return MessageResponse(message=message, commands=commands, tool_trace=tool_trace)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title='Minecraft LangGraph Gateway', version='0.1.0')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/healthz')
|
||||||
|
def healthz():
|
||||||
|
return {'ok': True, 'sessions': len(_sessions)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/v1/session/start', response_model=StartSessionResponse)
|
||||||
|
def start_session(req: StartSessionRequest):
|
||||||
|
s = _session_create(req.player, req.mode)
|
||||||
|
log.info('session start player=%s mode=%s session=%s', req.player, req.mode, s.session_id)
|
||||||
|
return StartSessionResponse(session_id=s.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/v1/session/{session_id}/message', response_model=MessageResponse)
|
||||||
|
def send_message(session_id: str, req: MessageRequest):
|
||||||
|
session = _session_get(session_id)
|
||||||
|
out = run_pipeline(session, req)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/v1/session/{session_id}/close')
|
||||||
|
def close_session(session_id: str):
|
||||||
|
with _sessions_lock:
|
||||||
|
existed = session_id in _sessions
|
||||||
|
_sessions.pop(session_id, None)
|
||||||
|
if not existed and _db_enabled():
|
||||||
|
existed = _db_get_by_id(session_id) is not None
|
||||||
|
_db_delete(session_id)
|
||||||
|
return {'closed': existed}
|
||||||
|
|
||||||
|
|
||||||
|
_db_init()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Minecraft LangGraph Gateway Sidecar
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 -m uvicorn langgraph_gateway:app --host 127.0.0.1 --port 8091 --workers 1
|
||||||
|
WorkingDirectory=/usr/local/bin
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -9,6 +9,26 @@
|
|||||||
"command_model": "qwen3-coder:30b",
|
"command_model": "qwen3-coder:30b",
|
||||||
"temperature": 0.85,
|
"temperature": 0.85,
|
||||||
"max_tokens": 700,
|
"max_tokens": 700,
|
||||||
|
"use_langgraph_gateway": true,
|
||||||
|
"langgraph_gateway_url": "http://127.0.0.1:8091",
|
||||||
|
"langgraph_gateway_timeout": 60,
|
||||||
|
"gateway_allow_tools_god": true,
|
||||||
|
"gateway_allow_tools_sudo": true,
|
||||||
|
"gateway_allow_tools_system": false,
|
||||||
|
"gateway_max_tool_steps": 4,
|
||||||
|
"gateway_shared_mode_sessions": [],
|
||||||
|
"template_sync_manifest_url": "",
|
||||||
|
"template_sync_urls": [
|
||||||
|
"https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge1.schem",
|
||||||
|
"https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge2.schem",
|
||||||
|
"https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge3.schem",
|
||||||
|
"https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/minecraft_structure.nbt"
|
||||||
|
],
|
||||||
|
"template_sync_max_files": 50,
|
||||||
|
"template_search_blocked_hosts": [
|
||||||
|
"planetminecraft.com",
|
||||||
|
"www.planetminecraft.com"
|
||||||
|
],
|
||||||
"cooldown_seconds": 10,
|
"cooldown_seconds": 10,
|
||||||
"max_commands_per_response": 8,
|
"max_commands_per_response": 8,
|
||||||
"interventions_per_day": 24,
|
"interventions_per_day": 24,
|
||||||
@@ -17,6 +37,11 @@
|
|||||||
"sudo_enabled": true,
|
"sudo_enabled": true,
|
||||||
"sudo_user": "slingshooter08",
|
"sudo_user": "slingshooter08",
|
||||||
"sudo_max_commands": 12,
|
"sudo_max_commands": 12,
|
||||||
|
"tp_border_guard_enabled": true,
|
||||||
|
"worldborder_center_x": 0,
|
||||||
|
"worldborder_center_z": 0,
|
||||||
|
"tp_border_margin": 2,
|
||||||
|
"template_max_bytes": 41943040,
|
||||||
"first_login_benevolence_enabled": true,
|
"first_login_benevolence_enabled": true,
|
||||||
"first_login_benevolence_max_commands": 12,
|
"first_login_benevolence_max_commands": 12,
|
||||||
"first_login_path": "/opt/paper-ai-25567/aigod_first_login_seen.json",
|
"first_login_path": "/opt/paper-ai-25567/aigod_first_login_seen.json",
|
||||||
|
|||||||
+1169
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ollama_url": "http://192.168.0.141:11434",
|
||||||
|
"message_model": "gemma3:12b",
|
||||||
|
"command_model": "qwen3-coder:30b",
|
||||||
|
"tool_model": "qwen2.5:1.5b",
|
||||||
|
"session_ttl_seconds": 21600,
|
||||||
|
"session_persistence_enabled": true,
|
||||||
|
"session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user