Add multi-turn Assistent session diagnostics to the Debug API.
POST /assistent/session + /chat with rich per-turn traces (patch, Exact merge, compact_context); chat-eval wraps one session turn. Docs playbook and unit/HTTP tests included. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+300
-30
@@ -1,8 +1,8 @@
|
||||
"""Localhost debug HTTP sidecar for gpu-rent.
|
||||
|
||||
Bind only 127.0.0.1. Started from `up` / `tunnel` / `debug`.
|
||||
Mostly read-only; `/assistent/chat-eval` is an opt-in AssistentChat probe
|
||||
(may load VRAM / persist chat) and is not part of `/snapshot`.
|
||||
Mostly read-only; Assistent session / chat-eval endpoints are opt-in
|
||||
(may load VRAM / persist chat) and are not part of `/snapshot`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -63,16 +63,25 @@ _CHAT_EVAL_PARAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
_WARN_VRAM = (
|
||||
"WARNING: may load chat model into VRAM, may write assistent.sqlite chat, "
|
||||
"and incurs GPU billing if the VM is up. Not part of /snapshot. "
|
||||
"compactContext / Exact merge are reconstructed client-side "
|
||||
"(Assistent HTTP API does not return them)."
|
||||
)
|
||||
|
||||
_OPENAPI: dict[str, Any] = {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "gpu-rent Debug API",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": (
|
||||
"Localhost diagnostics for installer progress and "
|
||||
"SwarmUI / Comfy / Ollama / Assistent. Mostly read-only; "
|
||||
"POST/GET /assistent/chat-eval is opt-in AssistentChat "
|
||||
"(may load VRAM / write chat) and is skipped from /snapshot."
|
||||
"SwarmUI / Comfy / Ollama / Assistent. Mostly read-only. "
|
||||
"Assistent multi-turn: POST /assistent/session + "
|
||||
"/assistent/session/{id}/chat (trace object). "
|
||||
"One-shot: POST/GET /assistent/chat-eval. "
|
||||
+ _WARN_VRAM
|
||||
),
|
||||
},
|
||||
"servers": [{"url": "http://127.0.0.1:17821"}],
|
||||
@@ -95,7 +104,9 @@ _OPENAPI: dict[str, Any] = {
|
||||
}
|
||||
},
|
||||
"/snapshot": {
|
||||
"get": {"summary": "Cheap aggregate (no full diag, no chat-eval)"}
|
||||
"get": {
|
||||
"summary": "Cheap aggregate (no full diag, no Assistent chat/session)"
|
||||
}
|
||||
},
|
||||
"/status": {"get": {"summary": "JSON mirror of gpu-rent status"}},
|
||||
"/state": {"get": {"summary": "state.json without secrets"}},
|
||||
@@ -142,6 +153,27 @@ _OPENAPI: dict[str, Any] = {
|
||||
],
|
||||
}
|
||||
},
|
||||
"/assistent/diagnose": {
|
||||
"get": {
|
||||
"summary": (
|
||||
"Full static+live Assistent health (logs on by default). "
|
||||
+ _WARN_VRAM
|
||||
),
|
||||
"parameters": [
|
||||
{
|
||||
"name": "chat_smoke",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean"},
|
||||
},
|
||||
{
|
||||
"name": "logs",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean", "default": True},
|
||||
"description": "Pass logs=0 to skip journal",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"/assistent/extension": {
|
||||
"get": {"summary": "DLL/Sqlite deps/Tab assets under Extensions/swarm-assistent"}
|
||||
},
|
||||
@@ -171,18 +203,92 @@ _OPENAPI: dict[str, Any] = {
|
||||
"/assistent/logs": {
|
||||
"get": {"summary": "journalctl swarmui filtered for Assistent/Sqlite/build"}
|
||||
},
|
||||
"/assistent/session": {
|
||||
"post": {
|
||||
"summary": (
|
||||
"Start multi-turn Assistent debug session "
|
||||
"(GetNewSession + optional GetConfig). " + _WARN_VRAM
|
||||
),
|
||||
"requestBody": {
|
||||
"required": False,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"persona": {"type": "string"},
|
||||
"pack": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Synthetic compactContext fields "
|
||||
"(krea_profile, checkpoint, "
|
||||
"recommended_params, …)"
|
||||
),
|
||||
},
|
||||
"probe_config": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/assistent/session/{id}": {
|
||||
"get": {"summary": "In-memory session summary + last trace"},
|
||||
"delete": {"summary": "Drop in-memory debug session"},
|
||||
},
|
||||
"/assistent/session/{id}/chat": {
|
||||
"post": {
|
||||
"summary": (
|
||||
"Send user message; returns reply + trace "
|
||||
"(patch, Exact merge, compact_context sizes, system_layers). "
|
||||
+ _WARN_VRAM
|
||||
),
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"timeout": {"type": "number"},
|
||||
"persona": {"type": "string"},
|
||||
"pack": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"context": {"type": "object"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/assistent/chat-eval": {
|
||||
"get": {
|
||||
"summary": (
|
||||
"Opt-in AssistentChat eval (GetNewSession→AssistentChat); "
|
||||
"may load VRAM / write chat — not in /snapshot"
|
||||
"One-shot AssistentChat (session+chat+delete). " + _WARN_VRAM
|
||||
),
|
||||
"parameters": _CHAT_EVAL_PARAMS,
|
||||
},
|
||||
"post": {
|
||||
"summary": (
|
||||
"Same as GET; prefer JSON body "
|
||||
"{message,persona,pack,model,timeout}"
|
||||
"{message,persona,pack,model,timeout,context}"
|
||||
),
|
||||
"requestBody": {
|
||||
"required": False,
|
||||
@@ -196,6 +302,7 @@ _OPENAPI: dict[str, Any] = {
|
||||
"pack": {"type": "string"},
|
||||
"model": {"type": "string"},
|
||||
"timeout": {"type": "number"},
|
||||
"context": {"type": "object"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -207,6 +314,21 @@ _OPENAPI: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def _is_assistent_session_chat(path: str) -> bool:
|
||||
parts = [p for p in path.split("/") if p]
|
||||
return (
|
||||
len(parts) == 4
|
||||
and parts[0] == "assistent"
|
||||
and parts[1] == "session"
|
||||
and parts[3] == "chat"
|
||||
)
|
||||
|
||||
|
||||
def _is_assistent_session_id(path: str) -> bool:
|
||||
parts = [p for p in path.split("/") if p]
|
||||
return len(parts) == 3 and parts[0] == "assistent" and parts[1] == "session"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebugHub:
|
||||
"""Shared state for the sidecar process."""
|
||||
@@ -397,7 +519,9 @@ def _make_handler(hub: DebugHub):
|
||||
f"<p>base <code>{hub.base_url}</code> · mostly read-only · 127.0.0.1</p>"
|
||||
f"<p>Agent: start at <a href='/openapi.json'>/openapi.json</a> "
|
||||
f"then <a href='/snapshot'>/snapshot</a>. "
|
||||
f"Opt-in chat: <code>/assistent/chat-eval</code> (not in snapshot).</p>"
|
||||
f"Opt-in Assistent: <code>/assistent/session</code> (multi-turn) · "
|
||||
f"<code>/assistent/chat-eval</code> (one-shot) — not in snapshot; "
|
||||
f"may load VRAM.</p>"
|
||||
f"<ul>{rows}</ul></body></html>"
|
||||
)
|
||||
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
|
||||
@@ -445,6 +569,19 @@ def _make_handler(hub: DebugHub):
|
||||
},
|
||||
)
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802
|
||||
try:
|
||||
self._dispatch("DELETE")
|
||||
except Exception as exc:
|
||||
self._json(
|
||||
500,
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(exc)[:300],
|
||||
"trace": traceback.format_exc()[-800:],
|
||||
},
|
||||
)
|
||||
|
||||
def _dispatch(self, method: str = "GET") -> None:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
@@ -464,12 +601,27 @@ def _make_handler(hub: DebugHub):
|
||||
doc["servers"] = [{"url": hub.base_url}]
|
||||
self._json(200, doc)
|
||||
return
|
||||
if method == "POST" and path != "/assistent/chat-eval":
|
||||
if method == "POST" and path not in {
|
||||
"/assistent/chat-eval",
|
||||
"/assistent/session",
|
||||
} and not _is_assistent_session_chat(path):
|
||||
self._json(
|
||||
405,
|
||||
{
|
||||
"ok": False,
|
||||
"error": "POST only allowed for /assistent/chat-eval",
|
||||
"error": (
|
||||
"POST only for /assistent/chat-eval, "
|
||||
"/assistent/session, /assistent/session/{id}/chat"
|
||||
),
|
||||
},
|
||||
)
|
||||
return
|
||||
if method == "DELETE" and not _is_assistent_session_id(path):
|
||||
self._json(
|
||||
405,
|
||||
{
|
||||
"ok": False,
|
||||
"error": "DELETE only for /assistent/session/{id}",
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -592,16 +744,38 @@ def _make_handler(hub: DebugHub):
|
||||
return
|
||||
if path == "/assistent" or path.startswith("/assistent/"):
|
||||
from gpu_rent import debug_assistent
|
||||
from gpu_rent import debug_assistent_session as das
|
||||
|
||||
def _flag(name: str) -> bool:
|
||||
raw = (qs.get(name) or ["0"])[0].strip().lower()
|
||||
def _flag(name: str, *, default: bool = False) -> bool:
|
||||
vals = qs.get(name)
|
||||
if not vals:
|
||||
return default
|
||||
raw = vals[0].strip().lower()
|
||||
return raw in {"1", "true", "yes", "on"}
|
||||
|
||||
chat_smoke = _flag("chat_smoke")
|
||||
include_logs = _flag("logs")
|
||||
sub = path[len("/assistent") :].lstrip("/") or ""
|
||||
try_list = [
|
||||
"/assistent",
|
||||
"/assistent/diagnose",
|
||||
"/assistent/extension",
|
||||
"/assistent/overlay",
|
||||
"/assistent/roles",
|
||||
"/assistent/memory",
|
||||
"/assistent/api",
|
||||
"/assistent/wanted",
|
||||
"/assistent/logs",
|
||||
"/assistent/session",
|
||||
"/assistent/session/{id}",
|
||||
"/assistent/session/{id}/chat",
|
||||
"/assistent/chat-eval",
|
||||
]
|
||||
|
||||
if sub == "":
|
||||
if method != "GET":
|
||||
self._json(405, {"ok": False, "error": "GET only"})
|
||||
return
|
||||
cache_key = f"assistent:{int(chat_smoke)}:{int(include_logs)}"
|
||||
payload = hub.cached(
|
||||
cache_key,
|
||||
@@ -612,6 +786,29 @@ def _make_handler(hub: DebugHub):
|
||||
include_logs=include_logs,
|
||||
),
|
||||
)
|
||||
elif sub == "diagnose":
|
||||
if method != "GET":
|
||||
self._json(405, {"ok": False, "error": "GET only"})
|
||||
return
|
||||
# logs default on for diagnose; logs=0 to skip
|
||||
diag_logs = _flag("logs", default=True)
|
||||
if qs.get("logs") and qs["logs"][0].strip().lower() in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}:
|
||||
diag_logs = False
|
||||
cache_key = f"assistent/diagnose:{int(chat_smoke)}:{int(diag_logs)}"
|
||||
payload = hub.cached(
|
||||
cache_key,
|
||||
CACHE_TTL_DEFAULT,
|
||||
lambda: das.collect_assistent_diagnose(
|
||||
hub.cfg,
|
||||
chat_smoke=chat_smoke,
|
||||
include_logs=diag_logs,
|
||||
),
|
||||
)
|
||||
elif sub == "extension":
|
||||
payload = hub.cached(
|
||||
"assistent/extension",
|
||||
@@ -657,8 +854,78 @@ def _make_handler(hub: DebugHub):
|
||||
CACHE_TTL_LOGS,
|
||||
lambda: debug_assistent.collect_assistent_logs(hub.cfg),
|
||||
)
|
||||
elif sub == "session" and method == "POST":
|
||||
try:
|
||||
body = self._read_json_body()
|
||||
except ValueError as exc:
|
||||
self._json(400, {"ok": False, "error": str(exc)})
|
||||
return
|
||||
skills = body.get("skills")
|
||||
if skills is not None and not isinstance(skills, list):
|
||||
self._json(
|
||||
400, {"ok": False, "error": "skills must be an array"}
|
||||
)
|
||||
return
|
||||
ctx = body.get("context")
|
||||
if ctx is not None and not isinstance(ctx, dict):
|
||||
self._json(
|
||||
400, {"ok": False, "error": "context must be an object"}
|
||||
)
|
||||
return
|
||||
probe = body.get("probe_config", True)
|
||||
payload = das.create_debug_session(
|
||||
hub.cfg,
|
||||
persona=body.get("persona"),
|
||||
pack=body.get("pack"),
|
||||
model=body.get("model"),
|
||||
context=ctx,
|
||||
skills=skills,
|
||||
probe_config=bool(probe),
|
||||
)
|
||||
elif _is_assistent_session_id(path):
|
||||
sid = sub.split("/", 1)[1]
|
||||
if method == "GET":
|
||||
payload = das.get_debug_session(sid)
|
||||
elif method == "DELETE":
|
||||
payload = das.delete_debug_session(sid)
|
||||
else:
|
||||
self._json(405, {"ok": False, "error": "GET or DELETE"})
|
||||
return
|
||||
elif _is_assistent_session_chat(path):
|
||||
if method != "POST":
|
||||
self._json(405, {"ok": False, "error": "POST only"})
|
||||
return
|
||||
sid = sub.split("/")[1]
|
||||
try:
|
||||
body = self._read_json_body()
|
||||
except ValueError as exc:
|
||||
self._json(400, {"ok": False, "error": str(exc)})
|
||||
return
|
||||
skills = body.get("skills")
|
||||
if skills is not None and not isinstance(skills, list):
|
||||
self._json(
|
||||
400, {"ok": False, "error": "skills must be an array"}
|
||||
)
|
||||
return
|
||||
ctx = body.get("context")
|
||||
if ctx is not None and not isinstance(ctx, dict):
|
||||
self._json(
|
||||
400, {"ok": False, "error": "context must be an object"}
|
||||
)
|
||||
return
|
||||
payload = das.chat_debug_session(
|
||||
hub.cfg,
|
||||
sid,
|
||||
message=body.get("message"),
|
||||
timeout=body.get("timeout"),
|
||||
context=ctx,
|
||||
pack=body.get("pack"),
|
||||
model=body.get("model"),
|
||||
skills=skills,
|
||||
persona=body.get("persona"),
|
||||
)
|
||||
elif sub == "chat-eval":
|
||||
body: dict[str, Any] = {}
|
||||
body = {}
|
||||
if method == "POST":
|
||||
try:
|
||||
body = self._read_json_body()
|
||||
@@ -666,12 +933,21 @@ def _make_handler(hub: DebugHub):
|
||||
self._json(400, {"ok": False, "error": str(exc)})
|
||||
return
|
||||
|
||||
def _q(name: str) -> str | None:
|
||||
def _q(name: str) -> Any:
|
||||
if name in body and body[name] is not None:
|
||||
return body[name]
|
||||
vals = qs.get(name)
|
||||
return vals[0] if vals else None
|
||||
|
||||
ctx = _q("context")
|
||||
if isinstance(ctx, str):
|
||||
try:
|
||||
ctx = json.loads(ctx)
|
||||
except json.JSONDecodeError:
|
||||
ctx = None
|
||||
if ctx is not None and not isinstance(ctx, dict):
|
||||
ctx = None
|
||||
|
||||
# Never cache — live AssistentChat / VRAM side effects.
|
||||
payload = debug_assistent.run_assistent_chat_eval(
|
||||
hub.cfg,
|
||||
@@ -680,6 +956,7 @@ def _make_handler(hub: DebugHub):
|
||||
pack=_q("pack"),
|
||||
model=_q("model"),
|
||||
timeout=_q("timeout"),
|
||||
context=ctx,
|
||||
)
|
||||
else:
|
||||
self._json(
|
||||
@@ -687,17 +964,7 @@ def _make_handler(hub: DebugHub):
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"unknown path {path}",
|
||||
"try": [
|
||||
"/assistent",
|
||||
"/assistent/extension",
|
||||
"/assistent/overlay",
|
||||
"/assistent/roles",
|
||||
"/assistent/memory",
|
||||
"/assistent/api",
|
||||
"/assistent/wanted",
|
||||
"/assistent/logs",
|
||||
"/assistent/chat-eval",
|
||||
],
|
||||
"try": try_list,
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -759,7 +1026,9 @@ def start_debug_server(
|
||||
f" {base}/openapi.json",
|
||||
f" {base}/snapshot",
|
||||
f" {base}/assistent",
|
||||
f" {base}/assistent/chat-eval (opt-in; not in snapshot)",
|
||||
f" {base}/assistent/diagnose",
|
||||
f" {base}/assistent/session (multi-turn; not in snapshot)",
|
||||
f" {base}/assistent/chat-eval (one-shot; not in snapshot)",
|
||||
]
|
||||
if log:
|
||||
for line in lines:
|
||||
@@ -796,7 +1065,8 @@ def run_debug_blocking(
|
||||
try:
|
||||
if log:
|
||||
log("Debug API слушает (Ctrl+C — выход). "
|
||||
"Мутаций конфига/GPU нет; /assistent/chat-eval — opt-in AssistentChat.")
|
||||
"Мутаций конфига/GPU нет; "
|
||||
"/assistent/session + /chat-eval — opt-in AssistentChat.")
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Reference in New Issue
Block a user