spark-agent.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  1. #!/usr/bin/env python3
  2. """Host agent: general MCP tools for one node, served through mentat.
  3. Runs beside the model containers rather than inside one, so the capabilities
  4. that need a host (reading every process, ptrace, kernel messages) leave the
  5. process that runs the model.
  6. Tools return what the underlying command printed. A parsed view discards the
  7. fields nobody thought to keep, and on 2026-08-25 the answer to where 100 GiB
  8. of memory went was in `nvidia-smi --query-compute-apps`, which the curated GPU
  9. tool did not carry. Correlating several sources is a separate tool, named for
  10. the question it answers.
  11. The agent registers its MCP endpoint with the local mentatd, and mentat-serve
  12. merges it with every other node's, so clients reach every node's tools
  13. through the router. Stdlib only, apart from the mentat shim's ray.register,
  14. and it runs without that. Prototype on a spare port with:
  15. python3 spark-agent.py --port 8090
  16. """
  17. import argparse
  18. import json
  19. import os
  20. import shutil
  21. import socket
  22. import subprocess
  23. import threading
  24. import time
  25. import urllib.parse
  26. import urllib.request
  27. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  28. class Server(ThreadingHTTPServer):
  29. """The HTTP listener.
  30. socketserver defaults `request_queue_size` to 5, and that is the listen()
  31. backlog. Past five connections the kernel has not yet handed over, it
  32. refuses new ones, and the refusal reaches the client as a transport error
  33. naming nothing -- it read as model failures through an afternoon of
  34. benchmarking before the listener was suspected. It must be a class
  35. attribute: TCPServer.__init__ calls listen() before the instance exists to
  36. set it on. The kernel clamps to net.core.somaxconn.
  37. """
  38. request_queue_size = 1024
  39. daemon_threads = True
  40. HOSTNAME = socket.gethostname()
  41. STARTED = time.time()
  42. LOG_DIR = os.environ.get("AGENT_LOG_DIR", "/logs")
  43. AGENT_PORT = int(os.environ.get("AGENT_PORT", "8090"))
  44. # Set from --port in main(); the page links peers on whatever port this
  45. # agent is actually served on rather than assuming the default.
  46. LISTEN_PORT = AGENT_PORT
  47. MAX_BYTES = 64 * 1024
  48. # mentat owns the model list: containers register with mentatd, and the
  49. # mentat-serve router decides what is routable. The agent reads the router's
  50. # table rather than keeping a registry of its own, so the two cannot disagree.
  51. MENTAT_ROUTER_URL = os.environ.get("MENTAT_ROUTER_URL", "")
  52. # How often the router and the engines are read. Token rates are differenced
  53. # across this interval, so it also sets how twitchy the numbers look.
  54. LOAD_INTERVAL_S = float(os.environ.get("LOAD_INTERVAL_S", "5"))
  55. def run(cmd: list[str], timeout: float = 15) -> str:
  56. """Combined stdout and stderr, or a line saying why there is none."""
  57. if not shutil.which(cmd[0]):
  58. return f"({cmd[0]} is not installed on {HOSTNAME})"
  59. try:
  60. r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  61. except subprocess.TimeoutExpired:
  62. return f"({' '.join(cmd)} timed out after {timeout}s)"
  63. except OSError as e:
  64. return f"({' '.join(cmd)} failed: {type(e).__name__}: {e})"
  65. return (r.stdout or "") + (r.stderr or "")
  66. def read_file(path: str) -> str:
  67. try:
  68. with open(path) as f:
  69. return f.read()
  70. except OSError as e:
  71. return f"({path}: {type(e).__name__}: {e})"
  72. def cap(text: str, limit: int = MAX_BYTES) -> str:
  73. b = text.encode("utf-8", "replace")
  74. if len(b) <= limit:
  75. return text
  76. head = b[:limit].decode("utf-8", "ignore")
  77. return f"{head}\n\n[truncated at {limit} bytes of {len(b)}]"
  78. def stamped(body: str, source: str) -> str:
  79. """Every answer says where it came from and when."""
  80. return (f"[{HOSTNAME} {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}] "
  81. f"{source}\n{body}")
  82. # --- raw tools -------------------------------------------------------------
  83. # One command in, its output out. Nothing between.
  84. def t_meminfo(**_):
  85. return stamped(read_file("/proc/meminfo"), "cat /proc/meminfo")
  86. def t_gpu(args: str = "", **_):
  87. """nvidia-smi with the caller's own flags, from a fixed set.
  88. --query-gpu reports N/A for memory on GB10 while --query-compute-apps
  89. still gives a per-pid figure, so the caller needs to pick.
  90. """
  91. allowed = {
  92. "": ["nvidia-smi"],
  93. "query": ["nvidia-smi", "--query-gpu=name,utilization.gpu,clocks.sm,"
  94. "temperature.gpu,power.draw,memory.total,memory.used",
  95. "--format=csv"],
  96. "apps": ["nvidia-smi", "--query-compute-apps=pid,process_name,used_memory",
  97. "--format=csv"],
  98. "topo": ["nvidia-smi", "topo", "-m"],
  99. "clocks": ["nvidia-smi", "-q", "-d", "CLOCK,PERFORMANCE"],
  100. }
  101. if args not in allowed:
  102. return f"unknown selection {args!r}. Available: {', '.join(sorted(allowed))}"
  103. return stamped(cap(run(allowed[args])), " ".join(allowed[args]))
  104. def t_processes(**_):
  105. return stamped(cap(run(["ps", "-eo",
  106. "pid,ppid,rss,pcpu,etime,nlwp,comm,args", "--sort=-rss"])),
  107. "ps -eo pid,ppid,rss,pcpu,etime,nlwp,comm,args --sort=-rss")
  108. def t_network(**_):
  109. parts = [("ip -o -4 addr", run(["ip", "-o", "-4", "addr"])),
  110. ("ip -o link", run(["ip", "-o", "link"])),
  111. ("rdma link show", run(["rdma", "link", "show"]))]
  112. return stamped("\n".join(f"$ {c}\n{o}" for c, o in parts), "network")
  113. def t_rdma_counters(device: str = "", **_):
  114. base = "/sys/class/infiniband"
  115. out = []
  116. try:
  117. devs = [device] if device else sorted(os.listdir(base))
  118. except OSError as e:
  119. return stamped(f"({base}: {e})", "rdma counters")
  120. for d in devs:
  121. for group in ("counters", "hw_counters"):
  122. p = f"{base}/{d}/ports/1/{group}"
  123. try:
  124. names = sorted(os.listdir(p))
  125. except OSError:
  126. continue
  127. out.append(f"# {d}/{group}")
  128. for n in names:
  129. out.append(f"{n} = {read_file(f'{p}/{n}').strip()}")
  130. return stamped(cap("\n".join(out) or "(no infiniband devices)"), "rdma counters")
  131. def t_pci(**_):
  132. return stamped(cap(run(["lspci", "-nnk"])), "lspci -nnk")
  133. def t_usb(**_):
  134. return stamped(cap(run(["lsusb", "-t"])), "lsusb -t")
  135. def t_docker_ps(**_):
  136. """Container inventory, from the host's snapshot.
  137. Reading it from a file keeps the Docker socket out of this process. The
  138. socket is root-equivalent on the host, and this endpoint answers a model.
  139. """
  140. p = os.path.join(LOG_DIR, "docker-ps.log")
  141. try:
  142. age = time.time() - os.stat(p).st_mtime
  143. except OSError as e:
  144. return stamped(f"({p}: {type(e).__name__}: {e})", "docker ps snapshot")
  145. return stamped(cap(read_file(p)), f"docker-ps.log, {age:.0f}s old")
  146. def t_systemd(**_):
  147. """Running services, from the host's snapshot.
  148. Same reason as t_docker_ps: this image has no systemctl, and the way to
  149. give it one is to mount the host's /run/systemd, which grants control and
  150. not only a read. So the host writes the list and this reads a file.
  151. """
  152. p = os.path.join(LOG_DIR, "systemd-units.log")
  153. try:
  154. age = time.time() - os.stat(p).st_mtime
  155. except OSError as e:
  156. return stamped(f"({p}: {type(e).__name__}: {e})", "systemd snapshot")
  157. return stamped(cap(read_file(p)), f"systemd-units.log, {age:.0f}s old")
  158. def t_list_logs(**_):
  159. try:
  160. names = sorted(os.listdir(LOG_DIR))
  161. except OSError as e:
  162. return stamped(f"({LOG_DIR}: {e})", "list logs")
  163. rows = []
  164. for n in names:
  165. p = os.path.join(LOG_DIR, n)
  166. try:
  167. st = os.stat(p)
  168. except OSError:
  169. continue
  170. rows.append(f"{st.st_size:>12} {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(st.st_mtime))} {n}")
  171. return stamped("\n".join(rows) or "(empty)", f"ls -l {LOG_DIR}")
  172. def t_read_log(file: str = "", lines: int = 200, grep: str = "", **_):
  173. """Tail of one file under the log directory, optionally filtered."""
  174. if not file:
  175. return "Name a file. Use list_logs to see them."
  176. root = os.path.realpath(LOG_DIR)
  177. target = os.path.realpath(os.path.join(root, file))
  178. if target != root and not target.startswith(root + os.sep):
  179. return "That path leaves the log directory."
  180. body = read_file(target)
  181. out = body.splitlines()
  182. if grep:
  183. try:
  184. import re
  185. rx = re.compile(grep)
  186. out = [l for l in out if rx.search(l)]
  187. except re.error as e:
  188. return f"(bad pattern: {e})"
  189. tail = out[-max(1, min(int(lines or 200), 5000)):]
  190. return stamped(cap("\n".join(tail)), f"{file} ({len(out)} matching lines)")
  191. def _engine_pids() -> list[tuple[int, str]]:
  192. """(host pid, label) for the engine processes, engine core first.
  193. These are container processes seen through the host's /proc, which is why
  194. the agent can dump them without the container holding CAP_SYS_PTRACE.
  195. """
  196. out = []
  197. try:
  198. entries = os.listdir("/proc")
  199. except OSError:
  200. return out
  201. for d in entries:
  202. if not d.isdigit():
  203. continue
  204. try:
  205. with open(f"/proc/{d}/cmdline", "rb") as f:
  206. cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
  207. except OSError:
  208. continue
  209. if "EngineCore" in cmd:
  210. out.insert(0, (int(d), "EngineCore"))
  211. elif "RayWorkerProc" in cmd:
  212. out.append((int(d), "RayWorkerProc"))
  213. elif "/bin/vllm serve" in cmd:
  214. out.append((int(d), "APIServer"))
  215. return out[:4]
  216. PY_SPY = os.environ.get("PY_SPY", "py-spy")
  217. def t_engine_stacks(rounds: int = 3, gap_s: float = 5, **_):
  218. """Python stacks of the engine processes, sampled several times.
  219. One sample cannot tell a slow kernel from queue backpressure. CUDA
  220. launches are async, so when the GPU stream backs up the launch queue fills
  221. and whatever the CPU tries to launch next is what blocks. A frame that
  222. stays put across rounds is the kernel actually running. A frame that moves
  223. means the cause is upstream of where the sample landed.
  224. """
  225. rounds = max(1, min(int(rounds or 3), 10))
  226. gap_s = max(0.5, min(float(gap_s or 5), 30))
  227. pids = _engine_pids()
  228. if not pids:
  229. return stamped("(no engine processes found)", "engine stacks")
  230. out = []
  231. for i in range(rounds):
  232. if i:
  233. time.sleep(gap_s)
  234. out.append(f"----- round {i+1}/{rounds} at "
  235. f"{time.strftime('%H:%M:%SZ', time.gmtime())} -----")
  236. for pid, label in pids:
  237. if label == "APIServer" and i:
  238. continue # waits on the engine by construction
  239. out.append(f"===== {label} (host pid {pid}) =====")
  240. out.append(run([PY_SPY, "dump", "--pid", str(pid)], timeout=30))
  241. return stamped(cap("\n".join(out)), f"{PY_SPY} dump x{rounds}")
  242. # --- tools that correlate ---------------------------------------------------
  243. def t_memory_accounting(**_):
  244. """Every byte of host memory, by owner.
  245. Separate from meminfo because it needs three sources: /proc/meminfo,
  246. per-process RSS, and the GPU's per-pid figure. GPU allocations skip
  247. process RSS entirely, so ps accounts for about 6 GiB of 121 on a serving
  248. node and the total only closes once the GPU number is added.
  249. """
  250. helper = os.environ.get("SPARK_MEMORY_PY", "/usr/local/bin/spark-memory.py")
  251. if os.path.exists(helper):
  252. return stamped(cap(run(["python3", helper, "-v"], timeout=30)), helper)
  253. return stamped(f"({helper} not present)", "memory accounting")
  254. def t_models(**_):
  255. """The router's view of every model, with the load this agent scraped."""
  256. cluster = router_snapshot()
  257. with _load_lock:
  258. loads = dict(_load)
  259. return json.dumps({"router": MENTAT_ROUTER_URL, **cluster,
  260. "load": loads}, indent=2)
  261. # --- load sampling ----------------------------------------------------------
  262. # The agent scrapes each engine the router lists rather than the engine
  263. # reporting its load. That keeps every model image out of this: a container
  264. # announces where it serves to mentat, and what it is doing is derived here.
  265. _load: dict[str, dict] = {}
  266. _load_lock = threading.Lock()
  267. _load_prev: dict[str, tuple[float, float, float]] = {}
  268. # The names are exact and were read off a running engine. kv_cache_usage_perc
  269. # is NOT gpu_cache_usage_perc, which is what the older status page scraped and
  270. # which silently reports nothing on this vLLM.
  271. _GAUGES = {
  272. "vllm:num_requests_running": "running",
  273. "vllm:num_requests_waiting": "waiting",
  274. "vllm:kv_cache_usage_perc": "kv",
  275. }
  276. _COUNTERS = {
  277. "vllm:prompt_tokens_total": "prompt",
  278. "vllm:generation_tokens_total": "gen",
  279. }
  280. def _parse_metrics(text: str) -> dict:
  281. """Pull the handful of series worth showing out of Prometheus text.
  282. Labels are ignored beyond stripping them: one engine per container here,
  283. so a bare sum over duplicate series is the right reading.
  284. """
  285. out: dict[str, float] = {}
  286. for line in text.splitlines():
  287. if not line or line[0] == "#":
  288. continue
  289. name, _, rest = line.partition("{")
  290. if rest:
  291. _, _, value = rest.partition("}")
  292. else:
  293. name, _, value = line.partition(" ")
  294. key = _GAUGES.get(name.strip()) or _COUNTERS.get(name.strip())
  295. if not key:
  296. continue
  297. try:
  298. out[key] = out.get(key, 0.0) + float(value.strip())
  299. except ValueError:
  300. continue
  301. return out
  302. def read_interconnect() -> dict:
  303. """This node's RoCE rates, from the host snapshot.
  304. The snapshot samples two counter reads a second apart, which is the only
  305. way to say whether the link is busy now rather than how much it has ever
  306. carried. Absent on a node with no cable plugged in, which is not an error.
  307. """
  308. p = os.path.join(LOG_DIR, "interconnect.log")
  309. try:
  310. age = time.time() - os.stat(p).st_mtime
  311. text = read_file(p)
  312. except OSError:
  313. return {}
  314. if age > 300: # a snapshot this old describes nothing
  315. return {}
  316. # The snapshot writes: "<dev> tx <n> MB/s rx <n> MB/s", so the units are
  317. # fields of their own and the rx label lands at index 4, not 3.
  318. for line in text.splitlines():
  319. parts = line.split()
  320. if len(parts) >= 7 and parts[1] == "tx" and parts[4] == "rx":
  321. try:
  322. return {"dev": parts[0], "tx_mbs": float(parts[2]),
  323. "rx_mbs": float(parts[5]), "age_s": round(age)}
  324. except ValueError:
  325. continue
  326. return {}
  327. # How long a driver fault stays interesting. Anything older is history: these
  328. # lines are never cleared, so without a window a single fault at boot would
  329. # read as a permanently broken node.
  330. DRIVER_ERR_WINDOW_S = 900
  331. def read_driver_errors() -> dict:
  332. """NVRM and Xid lines from the host snapshot, counted and dated.
  333. Returns {} when the snapshot is missing, stale or clean. `recent` counts
  334. the window; `total` is the whole boot, which can be far larger.
  335. This exists because nothing watched it. On 2026-08-27 the driver logged
  336. NV_ERR_NO_MEMORY every few minutes for 23 minutes while the model answered
  337. requests at normal speed, and the first visible symptom was a box that had
  338. to be power cycled.
  339. """
  340. p = os.path.join(LOG_DIR, "driver-errors.log")
  341. try:
  342. age = time.time() - os.stat(p).st_mtime
  343. text = read_file(p)
  344. except OSError:
  345. return {}
  346. if age > 300:
  347. return {}
  348. total, recent, newest, sample = 0, 0, None, ""
  349. now = time.time()
  350. for line in text.splitlines():
  351. if line.startswith("# matched "):
  352. try:
  353. total = int(line.split()[2])
  354. except (IndexError, ValueError):
  355. pass
  356. continue
  357. if not line.startswith("["):
  358. continue
  359. stamp, _, msg = line[1:].partition("] ")
  360. try:
  361. # dmesg -T renders "Thu Aug 27 22:13:26 2026" in local time.
  362. when = time.mktime(time.strptime(stamp, "%a %b %d %H:%M:%S %Y"))
  363. except ValueError:
  364. continue
  365. if newest is None or when > newest:
  366. newest, sample = when, msg.strip()[:200]
  367. if now - when <= DRIVER_ERR_WINDOW_S:
  368. recent += 1
  369. if not total and newest is None:
  370. return {}
  371. return {"total": total, "recent": recent, "sample": sample,
  372. "last_s": round(now - newest) if newest else None}
  373. # --- mentat ------------------------------------------------------------------
  374. # mentat does the cluster work this agent used to do itself. mentatd knows the
  375. # nodes, and mentat-serve routes the models and merges every group's MCP. The
  376. # agent registers its own MCP endpoint as a group of one named for the node, so
  377. # its host tools reach clients through the router, where `__group` picks the
  378. # node.
  379. MENTAT_GROUP = os.environ.get("MENTAT_GROUP", f"agent-{HOSTNAME}")
  380. MENTAT_DAEMON = os.environ.get("MENTAT_DAEMON", "127.0.0.1:6379")
  381. # Short, because one loop reads the router, every peer agent and every engine
  382. # in turn, and a dead node should cost a round seconds, not minutes.
  383. FETCH_TIMEOUT_S = 2.0
  384. def register_loop():
  385. """Hold this agent's registration with the local daemon.
  386. ray.register's main() refuses a registration with no OpenAI endpoint,
  387. because one usually means a model that never came up. This agent has no
  388. model on purpose, so it calls connect() and keeps main()'s retry shape.
  389. """
  390. try:
  391. from ray import register
  392. except ImportError as e:
  393. print(f"mentat registration off: {e}", flush=True)
  394. return
  395. args = register.parse_args([
  396. "--address", MENTAT_DAEMON, "--group", MENTAT_GROUP,
  397. "--mcp", f"{LISTEN_PORT}/mcp", "--container", "spark-agent"])
  398. delay = 1.0
  399. while True:
  400. try:
  401. if register.connect(args) > register.SETTLED_S:
  402. delay = 1.0
  403. except (OSError, RuntimeError, ValueError) as e:
  404. print(f"mentat registration: {type(e).__name__}: {e}", flush=True)
  405. time.sleep(delay)
  406. delay = min(delay * 2, register.RETRY_MAX_S)
  407. def _get_json(url: str) -> dict:
  408. with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_S) as r:
  409. return json.loads(r.read().decode())
  410. # The last complete read of the cluster: the router's table and what each
  411. # node's agent reported about itself.
  412. _cluster: dict = {"router": None, "router_error": "not read yet", "nodes": {}}
  413. _cluster_lock = threading.Lock()
  414. def router_snapshot() -> dict:
  415. with _cluster_lock:
  416. return dict(_cluster)
  417. def read_nodes(daemons: dict) -> dict[str, dict]:
  418. """daemon address -> that node's agent status, or why there is none.
  419. The router's watch set is the node list. mentat knows nothing of the
  420. interlink or the driver, so each node's agent is asked for its own.
  421. """
  422. out = {}
  423. for addr, d in sorted(daemons.items()):
  424. ip = addr.rsplit(":", 1)[0]
  425. entry = {"ip": ip, "addrs": [ip] + [a.rsplit(":", 1)[0]
  426. for a in d.get("alternates") or []],
  427. "connected": bool(d.get("connected")),
  428. "daemon_error": d.get("error")}
  429. try:
  430. entry["agent"] = _get_json(f"http://{ip}:{LISTEN_PORT}/status.json")
  431. except Exception as e:
  432. entry["agent_error"] = f"{type(e).__name__}: {e}"
  433. out[addr] = entry
  434. return out
  435. def watch_cluster():
  436. """Read the router and every node's agent, then scrape each engine.
  437. Token rates come from differencing the totals, because vLLM publishes
  438. counters and a counter says nothing about now.
  439. """
  440. while True:
  441. router, err = None, "MENTAT_ROUTER_URL is not set"
  442. if MENTAT_ROUTER_URL:
  443. try:
  444. router, err = _get_json(MENTAT_ROUTER_URL.rstrip("/") + "/"), None
  445. except Exception as e:
  446. err = f"{type(e).__name__}: {e}"
  447. nodes = read_nodes((router or {}).get("daemons") or {})
  448. with _cluster_lock:
  449. _cluster.update(router=router, router_error=err, nodes=nodes)
  450. groups = (router or {}).get("groups") or {}
  451. for name, g in groups.items():
  452. if g.get("openai"):
  453. sample_load(name, g["openai"])
  454. with _load_lock:
  455. for gone in set(_load) - set(groups):
  456. _load.pop(gone, None)
  457. time.sleep(LOAD_INTERVAL_S)
  458. def sample_load(name: str, url: str):
  459. parts = urllib.parse.urlsplit(url)
  460. try:
  461. with urllib.request.urlopen(
  462. f"{parts.scheme}://{parts.netloc}/metrics",
  463. timeout=FETCH_TIMEOUT_S) as r:
  464. m = _parse_metrics(r.read().decode("utf-8", "replace"))
  465. except Exception:
  466. with _load_lock:
  467. _load.pop(name, None)
  468. _load_prev.pop(name, None)
  469. return
  470. now = time.time()
  471. entry = {"running": int(m.get("running", 0)),
  472. "waiting": int(m.get("waiting", 0)),
  473. "kv": round(m.get("kv", 0.0) * 100, 1)}
  474. prev = _load_prev.get(name)
  475. if prev:
  476. dt = now - prev[0]
  477. # A restarted engine resets its counters, so a negative delta is a
  478. # new engine rather than a rate. Drop it and re-baseline.
  479. if dt >= 1.0:
  480. dp, dg = m.get("prompt", 0.0) - prev[1], m.get("gen", 0.0) - prev[2]
  481. if dp >= 0 and dg >= 0:
  482. entry["prompt_tps"] = round(dp / dt, 1)
  483. entry["gen_tps"] = round(dg / dt, 1)
  484. _load_prev[name] = (now, m.get("prompt", 0.0), m.get("gen", 0.0))
  485. with _load_lock:
  486. _load[name] = entry
  487. TOOLS = {
  488. "meminfo": (t_meminfo, "Raw /proc/meminfo.", {}),
  489. "gpu": (t_gpu, "nvidia-smi output. Pick a view with args: '' for the "
  490. "default table, 'query' for a csv of utilisation/clocks/temp/power, "
  491. "'apps' for per-pid GPU memory, 'topo' for the topology matrix, "
  492. "'clocks' for clock and performance detail. On GB10 --query-gpu "
  493. "reports N/A for memory while 'apps' still answers.",
  494. {"args": {"type": "string"}}),
  495. "processes": (t_processes, "Raw ps, sorted by RSS. GPU allocations do not "
  496. "appear here.", {}),
  497. "network": (t_network, "Raw ip addr, ip link and rdma link output.", {}),
  498. "rdma_counters": (t_rdma_counters, "Every RoCE counter for a port, raw. "
  499. "Rates need two reads: these are lifetime totals.",
  500. {"device": {"type": "string"}}),
  501. "pci": (t_pci, "Raw lspci -nnk, including the bound driver.", {}),
  502. "usb": (t_usb, "Raw lsusb -t.", {}),
  503. "docker_ps": (t_docker_ps, "Raw docker ps -a.", {}),
  504. "systemd_units": (t_systemd, "Running systemd services.", {}),
  505. "list_logs": (t_list_logs, "Files in the log directory with size and mtime.", {}),
  506. "read_log": (t_read_log, "Tail of one log file, optionally filtered by a "
  507. "regex. Capped at 64 KB.",
  508. {"file": {"type": "string"}, "lines": {"type": "integer"},
  509. "grep": {"type": "string"}}),
  510. "memory_accounting": (t_memory_accounting, "Host memory by owner, "
  511. "reconciled against MemTotal. Needs /proc/meminfo, "
  512. "per-process RSS and the GPU's per-pid figure "
  513. "together, which is why it is not raw meminfo.", {}),
  514. "engine_stacks": (t_engine_stacks, "Python stacks of the engine processes, "
  515. "sampled over several rounds. A frame that stays put is "
  516. "the kernel running; one that moves is queue "
  517. "backpressure, since an async CUDA launch blocks on "
  518. "whatever it tries to launch next. Reads container "
  519. "processes through the host's /proc.",
  520. {"rounds": {"type": "integer"},
  521. "gap_s": {"type": "number"}}),
  522. "models": (t_models, "mentat-serve's table of every group and model, "
  523. "with the token load this agent scraped from each engine.", {}),
  524. }
  525. # --- cluster page -----------------------------------------------------------
  526. # Every agent reads the same router, so any node can draw the cluster and
  527. # there is no page that only one node can serve.
  528. _CSS = """body{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
  529. max-width:60rem;margin:2rem auto;padding:0 1rem;background:#111;color:#ddd}
  530. h1{font-size:1.2rem}h2{font-size:1rem;margin-top:2rem;color:#9ad}
  531. table{border-collapse:collapse;width:100%;margin:.5rem 0}
  532. th,td{text-align:left;padding:.3rem .6rem;border-bottom:1px solid #333}
  533. th{color:#888;font-weight:normal}.ok{color:#7c7}.bad{color:#e77}.dim{color:#777}
  534. .self{color:#fc6}"""
  535. _DIM_DASH = "<span class=dim>-</span>"
  536. def _esc(v) -> str:
  537. return (str(v).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
  538. def _driver_cell(dv: dict) -> str:
  539. # A fault inside the window is the alarm. Older ones stay visible but dim,
  540. # because the count never resets short of a reboot.
  541. if dv.get("recent"):
  542. return (f"<span class=bad title=\"{_esc(dv.get('sample') or '')}\">"
  543. f"{dv['recent']} in {DRIVER_ERR_WINDOW_S // 60}m</span>")
  544. if dv.get("total"):
  545. return f"<span class=dim>{dv['total']} earlier</span>"
  546. return _DIM_DASH
  547. def cluster_page() -> bytes:
  548. snap = router_snapshot()
  549. router = snap.get("router") or {}
  550. groups = router.get("groups") or {}
  551. with _load_lock:
  552. loads = dict(_load)
  553. # An engine URL names an address. Map each address back to the node that
  554. # holds it, so the models table can say where a model runs.
  555. by_ip: dict[str, str] = {}
  556. for n in snap.get("nodes", {}).values():
  557. name = (n.get("agent") or {}).get("node") or n["ip"]
  558. for a in n["addrs"]:
  559. by_ip[a] = name
  560. def node_of(url: str | None) -> str:
  561. host = urllib.parse.urlsplit(url or "").hostname or ""
  562. return by_ip.get(host, host or "?")
  563. served = {}
  564. for g in groups.values():
  565. if g.get("openai"):
  566. n = node_of(g["openai"])
  567. served[n] = served.get(n, 0) + 1
  568. rows = []
  569. for n in snap.get("nodes", {}).values():
  570. agent = n.get("agent") or {}
  571. name = agent.get("node") or n["ip"]
  572. link = (f"<a href=\"http://{_esc(n['ip'])}:{LISTEN_PORT}/\" "
  573. f"style=\"color:#9ad\">{_esc(name)}</a>")
  574. if name == HOSTNAME:
  575. link += " <span class=self>this node</span>"
  576. mentat = ("<span class=ok>connected</span>" if n["connected"] else
  577. f"<span class=bad>{_esc(n.get('daemon_error') or 'down')}</span>")
  578. if "agent" in n:
  579. ic = agent.get("interconnect") or {}
  580. ics = (f"{ic['tx_mbs']:.0f}/{ic['rx_mbs']:.0f} MB/s"
  581. if ic else _DIM_DASH)
  582. drv = _driver_cell(agent.get("driver") or {})
  583. else:
  584. ics = f"<span class=bad title=\"{_esc(n.get('agent_error'))}\">no agent</span>"
  585. drv = _DIM_DASH
  586. rows.append(
  587. f"<tr><td>{link}</td><td>{_esc(', '.join(n['addrs']))}</td>"
  588. f"<td>{mentat}</td><td>{ics}</td><td>{drv}</td>"
  589. f"<td>{served.get(name, 0)}</td></tr>")
  590. models = []
  591. for name, g in sorted(groups.items()):
  592. if not g.get("openai"):
  593. continue # agents and other MCP-only groups
  594. healthy = bool(g.get("healthy"))
  595. ld = loads.get(name) or {}
  596. if "gen_tps" in ld:
  597. # Prompt and generation are separate on purpose: a long prefill
  598. # shows as prompt tokens with generation at zero, which is the
  599. # difference between a busy engine and a stalled one.
  600. tps = f"{ld['prompt_tps']:.0f} in / {ld['gen_tps']:.0f} out"
  601. elif ld:
  602. tps = "<span class=dim>warming</span>"
  603. else:
  604. tps = _DIM_DASH
  605. q = f"{ld['running']}/{ld['waiting']}" if ld else _DIM_DASH
  606. kv = f"{ld['kv']:.0f}%" if ld else _DIM_DASH
  607. models.append(
  608. f"<tr><td>{_esc(', '.join(g.get('models') or [name]))}</td>"
  609. f"<td>{_esc(name)}</td><td>{_esc(node_of(g['openai']))}</td>"
  610. f"<td class={'ok' if healthy else 'bad'}>"
  611. f"{'healthy' if healthy else _esc(g.get('why_not') or 'not ready')}</td>"
  612. f"<td>{tps}</td><td>{q}</td><td>{kv}</td>"
  613. f"<td class=dim>{_esc(g['openai'])}</td></tr>")
  614. err = snap.get("router_error")
  615. router_note = (f"<p class=bad>router unreachable: {_esc(err)}</p>" if err else "")
  616. empty = "router unreachable" if err else "none routed"
  617. html = f"""<!doctype html><meta charset=utf-8>
  618. <meta http-equiv=refresh content=10>
  619. <title>spark cluster</title><style>{_CSS}</style>
  620. <h1>spark cluster &middot; seen from {_esc(HOSTNAME)}</h1>
  621. {router_note}
  622. <h2>nodes</h2>
  623. <table><tr><th>node</th><th>addresses</th><th>mentatd</th>
  624. <th>interlink tx/rx</th><th>driver faults</th><th>models</th></tr>
  625. {''.join(rows) or '<tr><td colspan=6 class=dim>no daemons known</td></tr>'}</table>
  626. <h2>models</h2>
  627. <table><tr><th>model</th><th>group</th><th>node</th><th>state</th>
  628. <th>tokens/s</th><th>run/wait</th><th>kv</th>
  629. <th>serving</th></tr>{''.join(models) or f'<tr><td colspan=8 class=dim>{empty}</td></tr>'}</table>
  630. <p class=dim>from <a href="{_esc(MENTAT_ROUTER_URL)}/" style="color:#9ad">{_esc(MENTAT_ROUTER_URL)}</a>
  631. &middot; refreshes every 10s
  632. &middot; <a href="/status.json" style="color:#9ad">status.json</a></p>"""
  633. return html.encode()
  634. # --- who may ask ------------------------------------------------------------
  635. # The network is the only control: the endpoint answers our own subnets and
  636. # the router. Docker bridge ranges are absent on purpose, so a container on
  637. # this host cannot read host state just by being here. A bridge-networked
  638. # client still reaches the agent when it dials a real host address, because
  639. # Docker masquerades the source to the host's own IP on that route.
  640. # Address prefixes, not CIDR blocks. Loopback alone by default: the compose
  641. # file takes the real subnets from the node's .env.
  642. ALLOWED_SOURCES = [p.strip() for p in os.environ.get(
  643. "ALLOWED_SOURCES", "127.0.0.1").split(",") if p.strip()]
  644. # The router forwards every merged tool call, so its address is always let in.
  645. ROUTER_HOST = urllib.parse.urlsplit(MENTAT_ROUTER_URL).hostname or ""
  646. def source_allowed(addr: str) -> bool:
  647. return addr == ROUTER_HOST or any(addr.startswith(p) for p in ALLOWED_SOURCES)
  648. class Handler(BaseHTTPRequestHandler):
  649. protocol_version = "HTTP/1.1"
  650. def log_message(self, *a):
  651. pass
  652. def _deny_source(self) -> bool:
  653. addr = self.client_address[0]
  654. if source_allowed(addr):
  655. return False
  656. self._send(403, b'{"error":"source not permitted"}\n', "application/json")
  657. return True
  658. def _send(self, code: int, body: bytes, ctype: str):
  659. self.send_response(code)
  660. self.send_header("Content-Type", ctype)
  661. self.send_header("Content-Length", str(len(body)))
  662. self.end_headers()
  663. self.wfile.write(body)
  664. def do_GET(self):
  665. if self._deny_source():
  666. return
  667. path = self.path.rstrip("/") or "/"
  668. if path == "/":
  669. self._send(200, cluster_page(), "text/html; charset=utf-8")
  670. return
  671. if path in ("/healthz", "/status.json"):
  672. # Peer agents read this for the node table, so it carries this
  673. # node's interlink and driver figures.
  674. self._send(200, json.dumps({
  675. "node": HOSTNAME, "uptime_s": round(time.time() - STARTED, 1),
  676. "group": MENTAT_GROUP, "tools": sorted(TOOLS),
  677. "interconnect": read_interconnect(),
  678. "driver": read_driver_errors(),
  679. }, indent=2).encode(), "application/json")
  680. return
  681. self._send(404, b"not found\n", "text/plain")
  682. def do_POST(self):
  683. if self._deny_source():
  684. return
  685. path = self.path.rstrip("/") or "/"
  686. length = int(self.headers.get("Content-Length") or 0)
  687. raw = self.rfile.read(length) if length else b"{}"
  688. try:
  689. payload = json.loads(raw.decode() or "{}")
  690. except ValueError:
  691. self._send(400, b'{"error":"bad json"}', "application/json")
  692. return
  693. if path == "/mcp":
  694. self._send(200, json.dumps(mcp(payload)).encode(), "application/json")
  695. return
  696. self._send(404, b'{"error":"not found"}', "application/json")
  697. def mcp(req: dict) -> dict:
  698. rid, method = req.get("id"), req.get("method")
  699. def ok(result): return {"jsonrpc": "2.0", "id": rid, "result": result}
  700. if method == "initialize":
  701. return ok({"protocolVersion": "2024-11-05",
  702. "capabilities": {"tools": {"listChanged": False}},
  703. "serverInfo": {"name": f"spark-agent@{HOSTNAME}",
  704. "version": "0.2"}})
  705. if method == "tools/list":
  706. return ok({"tools": [{"name": n, "description": d,
  707. "inputSchema": {"type": "object",
  708. "properties": dict(p)}}
  709. for n, (_, d, p) in sorted(TOOLS.items())]})
  710. if method == "tools/call":
  711. params = req.get("params") or {}
  712. name = params.get("name")
  713. if name not in TOOLS:
  714. return ok({"content": [{"type": "text",
  715. "text": f"no tool {name!r} on {HOSTNAME}"}],
  716. "isError": True})
  717. try:
  718. text = TOOLS[name][0](**(params.get("arguments") or {}))
  719. except Exception as e:
  720. return ok({"content": [{"type": "text",
  721. "text": f"{type(e).__name__}: {e}"}],
  722. "isError": True})
  723. return ok({"content": [{"type": "text", "text": text}], "isError": False})
  724. return {"jsonrpc": "2.0", "id": rid,
  725. "error": {"code": -32601, "message": f"no method {method!r}"}}
  726. def main():
  727. ap = argparse.ArgumentParser()
  728. ap.add_argument("--port", type=int, default=8090)
  729. ap.add_argument("--bind", default="0.0.0.0")
  730. a = ap.parse_args()
  731. global LISTEN_PORT
  732. LISTEN_PORT = a.port
  733. threading.Thread(target=watch_cluster, daemon=True).start()
  734. threading.Thread(target=register_loop, daemon=True).start()
  735. srv = Server((a.bind, a.port), Handler)
  736. print(f"spark-agent on {a.bind}:{a.port} as {HOSTNAME}, group "
  737. f"{MENTAT_GROUP}, {len(TOOLS)} tools, logs at {LOG_DIR}", flush=True)
  738. srv.serve_forever()
  739. if __name__ == "__main__":
  740. main()