MosswartOverlord/agent/service.py
Erik 79cf88d3f7 feat(agent): Phase 1 — chat-window AI assistant via Claude Code subprocess
Adds an in-dashboard AI assistant that answers questions about live game
state. Designed reactively (no background loops) — every user message in
the chat window or via /api/agent/ask runs one `claude -p` invocation.

Architecture:
- New host-side FastAPI service (agent/) on 127.0.0.1:8767, OUTSIDE the
  dereth-tracker Docker container because `claude` and ~/.claude
  credentials live on the host.
- nginx routes /api/agent/* to the host service.
- The same browser session cookie the tracker issues authenticates
  agent requests (shared SECRET_KEY).
- The agent shells out to `claude -p --session-id <uuid>` with
  cwd=/home/erik/MosswartOverlord. Sessions persist as JSONL on disk
  via Claude Code's built-in machinery.
- An MCP stdio server (agent/mcp_overlord.py) exposes tools to Claude:
  get_live_players, get_recent_rares, query_telemetry_db (read-only,
  parsed by sqlglot to reject DML/DDL), get_player_state, get_inventory,
  get_inventory_search, get_combat_stats, get_equipment_cantrips,
  get_quest_status, get_server_health, suitbuilder_search.
- Read-only PG role (overlord_agent_ro) is the second line of defense
  on the SQL tool — even a parser bypass can't mutate.

Frontend:
- AgentWindow.tsx — draggable chat window with localStorage-pinned
  session UUID, "New Chat" button, on-mount rehydration from
  /agent/sessions/{id}/history (parses Claude Code's JSONL).
- Wired into WindowRenderer + Sidebar (🤖 Assistant button).

Operational:
- systemd unit (overlord-agent.service) + install.sh.
- agent/README.md documents env vars, deploy flow, smoke tests.
- nginx/overlord.conf gets a new /api/agent/ location with 180s timeout.
- CLAUDE.md gets an "Overlord Assistant Mode" section briefing the
  agent on which tools to use and how to behave.

NOT YET DEPLOYED — server still needs:
1. Apply agent/sql/0001_overlord_agent_ro.sql + ALTER ROLE password
2. Add AGENT_DB_DSN to /home/erik/MosswartOverlord/.env
3. bash agent/install.sh (creates venv, installs unit, starts service)
4. sudo cp /home/erik/MosswartOverlord/nginx/overlord.conf to nginx + reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:43:59 +02:00

213 lines
7 KiB
Python

"""Overlord Agent host-side FastAPI service.
Runs OUTSIDE Docker (host-side) on port 8767.
Endpoints:
GET /agent/health — liveness check
POST /agent/sessions/new — returns a fresh session UUID
POST /agent/ask — runs claude -p with given session
GET /agent/sessions/{session_id}/history
— replays a session's JSONL on disk
Auth: every endpoint except /health requires the same browser session
cookie that dereth-tracker issues.
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from . import auth
from .claude_wrapper import CLAUDE_CWD, ClaudeError, ask_claude
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("agent")
app = FastAPI(title="Overlord Agent", version="0.1.0")
# ─── Models ──────────────────────────────────────────────────────────
class AskRequest(BaseModel):
session_id: str = Field(
..., description="Stable per-conversation UUID stored in browser localStorage"
)
message: str = Field(..., min_length=1, max_length=10_000)
class AskResponse(BaseModel):
result: str
session_id: str
duration_ms: int
num_turns: int
is_error: bool
class NewSessionResponse(BaseModel):
session_id: str
# ─── Helpers ─────────────────────────────────────────────────────────
def _encode_cwd(cwd: str) -> str:
"""Match Claude Code's on-disk encoding for cwd → directory name.
Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
where non-alphanumerics in the cwd are replaced with hyphens.
Example: /home/erik/MosswartOverlord → -home-erik-MosswartOverlord
"""
return "".join(c if c.isalnum() else "-" for c in cwd)
def _sessions_dir() -> Path:
return Path.home() / ".claude" / "projects" / _encode_cwd(CLAUDE_CWD)
# ─── Endpoints ───────────────────────────────────────────────────────
@app.get("/agent/health")
async def health() -> dict:
"""Liveness probe — no auth, used by deployment scripts."""
return {
"status": "ok",
"claude_cwd": CLAUDE_CWD,
"sessions_dir_exists": _sessions_dir().exists(),
}
@app.post("/agent/sessions/new", response_model=NewSessionResponse)
async def new_session(_user: dict = Depends(auth.require_user)) -> NewSessionResponse:
"""Generate a fresh session UUID. Doesn't touch disk — claude creates the
JSONL file when the first message lands."""
return NewSessionResponse(session_id=str(uuid.uuid4()))
@app.post("/agent/ask", response_model=AskResponse)
async def agent_ask(
req: AskRequest, user: dict = Depends(auth.require_user)
) -> AskResponse:
"""Forward a message to claude -p resuming the given session."""
started = time.monotonic()
try:
result = await ask_claude(req.message, req.session_id)
except ClaudeError as e:
logger.warning(
"claude failed user=%s session=%s err=%s", user["username"], req.session_id, e
)
raise HTTPException(status_code=502, detail=str(e))
elapsed_ms = int((time.monotonic() - started) * 1000)
logger.info(
"ask user=%s session=%s turns=%d duration_ms=%d (subprocess=%dms)",
user["username"],
result.session_id,
result.num_turns,
elapsed_ms,
result.duration_ms,
)
return AskResponse(
result=result.result,
session_id=result.session_id,
duration_ms=result.duration_ms,
num_turns=result.num_turns,
is_error=result.is_error,
)
@app.get("/agent/sessions/{session_id}/history")
async def session_history(
session_id: str, _user: dict = Depends(auth.require_user)
) -> JSONResponse:
"""Replay a session's JSONL from ~/.claude/projects/.../<id>.jsonl.
Returns a flat array of {role, text, timestamp} for the chat window.
Returns an empty array if the session file doesn't exist yet.
"""
# UUID sanity check to prevent path traversal — claude Code uses uuid4
try:
uuid.UUID(session_id)
except ValueError:
raise HTTPException(status_code=400, detail="invalid session_id")
path = _sessions_dir() / f"{session_id}.jsonl"
if not path.is_file():
return JSONResponse({"messages": []})
messages: list[dict[str, Any]] = []
try:
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# Claude Code records turns with type=user / type=assistant.
# Tool-use traffic is verbose; skip it for the chat UI.
msg_type = obj.get("type")
if msg_type not in ("user", "assistant"):
continue
msg = obj.get("message") or {}
content = msg.get("content")
# `content` may be a string or list[{type,text}].
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
else:
text = ""
if not text:
continue
messages.append(
{
"role": msg_type,
"text": text,
"timestamp": obj.get("timestamp"),
}
)
except OSError as e:
logger.warning("failed to read session %s: %s", session_id, e)
raise HTTPException(status_code=500, detail="failed to read session")
return JSONResponse({"messages": messages})
# ─── Entrypoint ──────────────────────────────────────────────────────
def main() -> None:
"""Run via `python -m agent.service` for local testing."""
import uvicorn
uvicorn.run(
"agent.service:app",
host="127.0.0.1",
port=8767,
log_level="info",
)
if __name__ == "__main__":
main()