diff --git a/agents/agent_loop.py b/agents/agent_loop.py index ddf5c50c..8c5de693 100644 --- a/agents/agent_loop.py +++ b/agents/agent_loop.py @@ -609,6 +609,16 @@ def run_agent_loop(profile_name: str, initial_prompt: str, interval: int = 30): send_agent_heartbeat(next_turn_in=interval, turn_in_progress=False) continue + # Pause check โ€” dashboard can pause heartbeat context without restarting service + try: + with urllib.request.urlopen(f"{MC_API_URL}/agent/paused", timeout=2) as _pr: + if json.loads(_pr.read().decode()).get("paused"): + print("[loop] Paused by dashboard โ€” skipping turn", flush=True) + send_agent_heartbeat(next_turn_in=None, turn_in_progress=False) + continue + except Exception: + pass + # Gather world state print(f"[loop] Turn {turn_count} โ€” gathering state...", flush=True) turn_in_progress.set() diff --git a/agents/bot/dashboard.html b/agents/bot/dashboard.html index b729edbb..e56e852c 100644 --- a/agents/bot/dashboard.html +++ b/agents/bot/dashboard.html @@ -316,6 +316,18 @@ cursor: not-allowed; } .bp-save-bar button:hover:not(:disabled) { background: #2ea043; } + /* Admin Controls panel */ + .adm-row { display: flex; gap: 8px; align-items: center; padding: 6px 0; flex-wrap: wrap; } + .adm-row label { color: #8b949e; font-size: 0.8rem; min-width: 72px; } + .adm-row input[type=text], .adm-row input[type=number], .adm-row input[type=password] { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 4px 8px; border-radius: 4px; font-size: 0.82rem; } + .adm-row button { + background: #21262d; border: 1px solid #30363d; color: #c9d1d9; + padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8rem; } + .adm-row button:hover { background: #30363d; } + #adm-status { font-size: 0.78rem; color: #8b949e; padding-top: 4px; } + #adm-agent-state { font-size: 0.78rem; color: #7ee787; } @@ -390,6 +402,31 @@

๐ŸŽฎ DaemonCraft Dashboard โ€” Bot

Waiting for agent turns...
+ + +
+
Admin Controls
+
+
+ + + : + + +
+
+ + +
+
+ + + + +
+
+
+
diff --git a/agents/bot/server.js b/agents/bot/server.js index 1c0a595a..7ed01e62 100644 --- a/agents/bot/server.js +++ b/agents/bot/server.js @@ -150,10 +150,19 @@ function savePlan(plan) { // Configuration // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Override file โ€” written by POST /admin/config, loaded at startup +const BOT_OVERRIDE_PATH = path.join(path.dirname(new URL(import.meta.url).pathname), "bot-override.json"); +let fileOverrides = {}; +try { + if (fs.existsSync(BOT_OVERRIDE_PATH)) { + fileOverrides = JSON.parse(fs.readFileSync(BOT_OVERRIDE_PATH, "utf8")); + } +} catch (_) {} + const config = { mc: { - host: process.env.MC_HOST || 'localhost', - port: parseInt(process.env.MC_PORT || '25565'), + host: fileOverrides.mc_host || process.env.MC_HOST || 'localhost', + port: fileOverrides.mc_port || parseInt(process.env.MC_PORT || '25565'), username: process.env.MC_USERNAME || 'HermesBot', auth: process.env.MC_AUTH || 'offline', }, @@ -209,6 +218,7 @@ let actionHistory = []; // { action, status, time } const MAX_ACTION_HISTORY = 100; let agentLog = []; // { turn, time, prompt, response, tool_calls, error } const MAX_AGENT_LOG = 50; +let agentPaused = false; let agentHeartbeat = { nextTurnIn: null, turnInProgress: false }; // countdown for dashboard // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -3240,13 +3250,22 @@ function respond(res, status, data) { res.end(JSON.stringify(data)); } +function requireAdminToken(req, res) { + const token = process.env.DASHBOARD_TOKEN; + if (!token) return true; + const auth = req.headers['authorization'] || ''; + if (auth === `Bearer ${token}`) return true; + respond(res, 401, { ok: false, error: 'Unauthorized' }); + return false; +} + const httpServer = http.createServer(async (req, res) => { // CORS preflight if (req.method === 'OPTIONS') { res.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }); return res.end(); } @@ -3266,6 +3285,21 @@ const httpServer = http.createServer(async (req, res) => { }); } + if (path === '/config') { + return respond(res, 200, { + ok: true, + mc_host: config.mc.host, + mc_port: config.mc.port, + username: config.mc.username, + auth: config.mc.auth, + api_port: config.api.port, + }); + } + + if (path === '/agent/paused') { + return respond(res, 200, { ok: true, paused: agentPaused }); + } + if (path === '/status') { return respond(res, 200, { ok: true, data: getFullState() }); } @@ -3850,6 +3884,39 @@ const httpServer = http.createServer(async (req, res) => { // Synchronous action: POST /action/ACTION (still supported for quick stuff) const actionMatch = path.match(/^\/action\/(\w+)$/); if (!actionMatch) { + // โ”€โ”€ Admin: restart, config switch, pause/resume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (path === '/admin/restart') { + if (!requireAdminToken(req, res)) return; + respond(res, 200, { ok: true, message: 'Restarting in 1sโ€ฆ' }); + setTimeout(() => process.exit(0), 1000); + return; + } + + if (path === '/admin/config') { + if (!requireAdminToken(req, res)) return; + const { mc_host, mc_port } = body; + if (!mc_host || !mc_port) return respond(res, 400, { ok: false, error: 'mc_host and mc_port required' }); + const current = fs.existsSync(BOT_OVERRIDE_PATH) + ? JSON.parse(fs.readFileSync(BOT_OVERRIDE_PATH, 'utf8')) + : {}; + fs.writeFileSync(BOT_OVERRIDE_PATH, JSON.stringify({ ...current, mc_host, mc_port: parseInt(mc_port) }, null, 2)); + respond(res, 200, { ok: true, mc_host, mc_port, message: 'Config saved, restartingโ€ฆ' }); + setTimeout(() => process.exit(0), 1000); + return; + } + + if (path === '/admin/pause') { + if (!requireAdminToken(req, res)) return; + agentPaused = true; + return respond(res, 200, { ok: true, paused: true }); + } + + if (path === '/admin/resume') { + if (!requireAdminToken(req, res)) return; + agentPaused = false; + return respond(res, 200, { ok: true, paused: false }); + } + // Special: /connect if (path === '/connect') { await createBot(); diff --git a/agents/bot/smoke_test.py b/agents/bot/smoke_test.py new file mode 100755 index 00000000..c12f0452 --- /dev/null +++ b/agents/bot/smoke_test.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Smoke tests for DaemonCraft bot server (server.js). + +Tests all major GET endpoints and the new admin endpoints. +Run from saicam1: + python3 /home/siqui/DaemonCraft/agents/bot/smoke_test.py +Or from any host: + python3 smoke_test.py --base-url http://10.130.40.202:3002 +""" + +import argparse +import json +import sys +import urllib.request +import urllib.error +from typing import Any + +DEFAULT_BASE = "http://localhost:3002" +PASS = "\033[32mโœ“\033[0m" +FAIL = "\033[31mโœ—\033[0m" +WARN = "\033[33mโš \033[0m" + +failures = 0 + + +def get(path: str, base: str = DEFAULT_BASE) -> tuple[int, Any]: + url = base.rstrip("/") + path + try: + with urllib.request.urlopen(url, timeout=5) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, {} + except Exception as e: + return 0, {"_error": str(e)} + + +def post(path: str, body: dict = None, base: str = DEFAULT_BASE) -> tuple[int, Any]: + url = base.rstrip("/") + path + data = json.dumps(body or {}).encode() + req = urllib.request.Request(url, data=data, + headers={"Content-Type": "application/json"}, + method="POST") + try: + with urllib.request.urlopen(req, timeout=5) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read()) + except Exception: + return e.code, {} + except Exception as e: + return 0, {"_error": str(e)} + + +def check(label: str, cond: bool, detail: str = ""): + global failures + sym = PASS if cond else FAIL + suffix = f" ({detail})" if detail else "" + print(f" {sym} {label}{suffix}") + if not cond: + failures += 1 + + +def section(title: str): + print(f"\n{title}") + print("โ”€" * (len(title) + 2)) + + +def main(base: str): + print(f"DaemonCraft smoke tests โ€” {base}\n") + + # โ”€โ”€ GET endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section("GET /health (connectivity baseline)") + code, body = get("/health", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + check("username present", bool(body.get("username")), repr(body.get("username"))) + + section("GET /config (new โ€” returns runtime MC config)") + code, body = get("/config", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + check("mc_host present", bool(body.get("mc_host")), repr(body.get("mc_host"))) + check("mc_port is int", isinstance(body.get("mc_port"), int), repr(body.get("mc_port"))) + check("username present", bool(body.get("username"))) + check("api_port is int", isinstance(body.get("api_port"), int)) + + section("GET /agent/paused (new โ€” pause flag)") + code, body = get("/agent/paused", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + check("paused is bool", isinstance(body.get("paused"), bool), repr(body.get("paused"))) + check("paused is false at startup", body.get("paused") is False) + + section("GET /status") + code, body = get("/status", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + check("data object present", isinstance(body.get("data"), dict)) + + section("GET /chat") + code, body = get("/chat?count=5", base) + check("HTTP 200", code == 200, f"got {code}") + check("messages is list", isinstance(body.get("data", {}).get("messages"), list)) + + section("GET /plan") + code, body = get("/plan", base) + check("HTTP 200", code == 200, f"got {code}") + check("data object", isinstance(body.get("data"), dict)) + + section("GET /actions") + code, body = get("/actions", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + data = body.get("data") + check("data present", data is not None, repr(type(data))) + + section("GET /agent/log (Bot Mind panel data)") + code, body = get("/agent/log", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + turns = body.get("data", {}).get("turns", None) + check("turns is list", isinstance(turns, list)) + if isinstance(turns, list) and len(turns) == 0: + print(f" {WARN} turns is empty โ€” expected if agent uses gateway mode (heartbeat injector)") + + section("GET /inventory") + code, body = get("/inventory", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + + section("GET /nearby") + code, body = get("/nearby?radius=16", base) + check("HTTP 200", code == 200, f"got {code}") + check("ok: true", body.get("ok") is True) + + section("GET /social") + code, body = get("/social", base) + check("HTTP 200", code == 200, f"got {code}") + + section("GET /deaths") + code, body = get("/deaths", base) + check("HTTP 200", code == 200, f"got {code}") + + section("GET /task") + code, body = get("/task", base) + check("HTTP 200", code == 200, f"got {code}") + + section("GET /commands") + code, body = get("/commands", base) + check("HTTP 200", code == 200, f"got {code}") + + section("GET /dashboard (HTML page)") + try: + with urllib.request.urlopen(base.rstrip("/") + "/dashboard", timeout=5) as _r: + _dash_code = _r.status + except Exception as _e: + _dash_code = 0 + check("HTTP 200", _dash_code == 200, f"got {_dash_code}") + + # โ”€โ”€ Dashboard HTML structure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section("Dashboard HTML โ€” panel structure") + try: + with urllib.request.urlopen(base.rstrip("/") + "/dashboard", timeout=5) as r: + html = r.read().decode("utf-8") + expected_panels = ["status", "plan", "chat", "actions", "inventory", + "task", "agent", "admin"] + for panel in expected_panels: + check(f'data-panel="{panel}"', f'data-panel="{panel}"' in html) + check("Admin Controls label", "Admin Controls" in html) + check("adm-host input", 'id="adm-host"' in html) + check("adm-port input", 'id="adm-port"' in html) + check("adminRestart function", "adminRestart" in html) + check("adminSwitchServer function", "adminSwitchServer" in html) + check("adminPause function", "adminPause" in html) + check("adminResume function", "adminResume" in html) + check("adminLoadConfig on DOMContentLoaded", "adminLoadConfig" in html) + except Exception as e: + check("HTML fetch", False, str(e)) + + # โ”€โ”€ Admin endpoints: pause/resume (reversible) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section("POST /admin/pause + /admin/resume (reversible round-trip)") + code, body = post("/admin/pause", base=base) + check("pause HTTP 200", code == 200, f"got {code}") + check("pause ok:true", body.get("ok") is True) + check("pause paused:true", body.get("paused") is True) + + _, state = get("/agent/paused", base) + check("GET /agent/paused reflects pause", state.get("paused") is True) + + code, body = post("/admin/resume", base=base) + check("resume HTTP 200", code == 200, f"got {code}") + check("resume ok:true", body.get("ok") is True) + check("resume paused:false", body.get("paused") is False) + + _, state = get("/agent/paused", base) + check("GET /agent/paused reflects resume", state.get("paused") is False) + + # POST /admin/restart is intentionally NOT tested (would restart the service) + + # โ”€โ”€ Unknown routes return 404 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + section("404 for unknown routes") + code, _ = get("/no-such-endpoint-xyz", base) + check("GET unknown โ†’ 404", code == 404, f"got {code}") + + # โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + total = 0 + # Count all check() calls + print(f"\n{'โ”€'*40}") + if failures == 0: + print(f"\033[32mAll checks passed\033[0m") + else: + print(f"\033[31m{failures} check(s) failed\033[0m") + return failures + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default=DEFAULT_BASE, + help=f"Bot API base URL (default: {DEFAULT_BASE})") + args = parser.parse_args() + sys.exit(main(args.base_url))