Files
gpu-rent/src/gpu_rent/debug_assistent_session.py
T
Leonid PershinandCursor b83d1d1e9c 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>
2026-08-23 07:32:58 +03:00

1164 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""In-memory Assistent debug chat sessions for the laptop sidecar.
Multi-turn AssistentChat with a reconstructed per-turn **trace** (patch, Exact
merge hints, compact_context sizes, system_layers). Sessions live only in the
debug process (dict + lock); not part of /snapshot.
Gaps (documented in every trace.gaps):
- compactContext / Exact merge / krea_profile are client-side in swarm-assistent;
HTTP AssistentChat does not return them — we send a synthetic context_json and
optionally probe AssistentGetConfig / ListModels.
- Tool hop names are only visible on AssistentChatWS (clear_stream/hop); HTTP
returns the final reply + system_layers / civitai_results only.
- Park/Warm are Generate-path APIs (AssistentParkLlm / AssistentWarmLlm), not
invoked by chat turns.
"""
from __future__ import annotations
import json
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from gpu_rent.config import Config
from gpu_rent.debug_assistent import (
DEFAULT_CHAT_EVAL_MESSAGE,
MAX_CHAT_EVAL_TIMEOUT,
_clamp_chat_eval_timeout,
_finish_chat_eval,
_http_json,
_resolve_chat_model,
_session_id,
_summarize_patch,
_swarm_base,
collect_assistent_deep,
extract_assistent_patch,
)
SESSION_TTL_SEC = 45 * 60
MAX_SESSIONS = 32
MAX_MESSAGE_CHARS = 8_000
MAX_HISTORY_TURNS = 40 # user+assistant pairs roughly
EXACT_GENERATE_PARAM_KEYS = ("steps", "cfg", "sigma_shift")
_TRACE_GAPS = [
"compactContext is client-side only — AssistentChat never returns it; "
"debug API sends synthetic context_json and summarizes sizes",
"Exact merge / recommended_params / krea_profile are reconstructed from "
"request context + optional AssistentGetConfig.exact (not live Swarm UI)",
"HTTP AssistentChat does not list tool hops; WS emits hop notices — "
"infer ask:* from patch.ask only",
"Park/Warm not run on chat turns (AssistentParkLlm/WarmLlm are Generate-path)",
"Sqlite SaveChat is client-side; AssistentChat may still return reply when "
"memory DLL is missing — soft error in trace.errors",
]
@dataclass
class DebugChatSession:
debug_session_id: str
swarm_session_id: str | None
persona: str
pack: str
model: str | None
preferred: str | None
via: str
messages: list[dict[str, str]] = field(default_factory=list)
context: dict[str, Any] = field(default_factory=dict)
skills: list[str] = field(default_factory=list)
config_probe: dict[str, Any] | None = None
created_at: float = field(default_factory=time.time)
last_used: float = field(default_factory=time.time)
turn_count: int = 0
last_trace: dict[str, Any] | None = None
warnings: list[str] = field(default_factory=list)
def touch(self) -> None:
self.last_used = time.time()
def summary(self) -> dict[str, Any]:
return {
"debug_session_id": self.debug_session_id,
"swarm_session_id": self.swarm_session_id,
"persona": self.persona,
"pack": self.pack,
"model": self.model,
"preferred": self.preferred,
"via": self.via,
"turn_count": self.turn_count,
"message_count": len(self.messages),
"skills": list(self.skills),
"context_keys": sorted(self.context.keys())[:40],
"created_at": self.created_at,
"last_used": self.last_used,
"ttl_sec": SESSION_TTL_SEC,
"expires_in_sec": max(0, int(SESSION_TTL_SEC - (time.time() - self.last_used))),
"has_config_probe": self.config_probe is not None,
"warnings": list(self.warnings),
"last_trace_ok": (self.last_trace or {}).get("ok"),
}
class SessionStore:
"""Thread-safe in-memory sessions with TTL eviction."""
def __init__(
self,
*,
ttl_sec: float = SESSION_TTL_SEC,
max_sessions: int = MAX_SESSIONS,
) -> None:
self.ttl_sec = ttl_sec
self.max_sessions = max_sessions
self._lock = threading.Lock()
self._sessions: dict[str, DebugChatSession] = {}
def purge_expired(self) -> int:
now = time.time()
with self._lock:
dead = [
sid
for sid, s in self._sessions.items()
if now - s.last_used > self.ttl_sec
]
for sid in dead:
del self._sessions[sid]
return len(dead)
def put(self, session: DebugChatSession) -> None:
with self._lock:
self._evict_unlocked()
while len(self._sessions) >= self.max_sessions and self._sessions:
oldest = min(self._sessions.values(), key=lambda s: s.last_used)
del self._sessions[oldest.debug_session_id]
self._sessions[session.debug_session_id] = session
def get(self, session_id: str) -> DebugChatSession | None:
with self._lock:
self._evict_unlocked()
s = self._sessions.get(session_id)
if s is None:
return None
if time.time() - s.last_used > self.ttl_sec:
del self._sessions[session_id]
return None
s.touch()
return s
def delete(self, session_id: str) -> bool:
with self._lock:
return self._sessions.pop(session_id, None) is not None
def list_ids(self) -> list[str]:
with self._lock:
self._evict_unlocked()
return list(self._sessions.keys())
def _evict_unlocked(self) -> None:
now = time.time()
dead = [
sid
for sid, s in self._sessions.items()
if now - s.last_used > self.ttl_sec
]
for sid in dead:
del self._sessions[sid]
# Process-global store for the debug sidecar.
_STORE = SessionStore()
def get_session_store() -> SessionStore:
return _STORE
def reset_session_store_for_tests() -> SessionStore:
"""Replace the global store (tests only)."""
global _STORE
_STORE = SessionStore()
return _STORE
def clamp_message(text: str | None, *, max_chars: int = MAX_MESSAGE_CHARS) -> tuple[str, bool]:
raw = (text or "").strip()
if len(raw) <= max_chars:
return raw, False
return raw[:max_chars], True
def summarize_compact_context(context: dict[str, Any] | None) -> dict[str, Any]:
"""Best-effort compactContext summary (keys + sizes + truncated preview)."""
ctx = context if isinstance(context, dict) else {}
sizes: dict[str, int] = {}
for k, v in ctx.items():
try:
sizes[k] = len(json.dumps(v, ensure_ascii=False))
except (TypeError, ValueError):
sizes[k] = len(str(v))
blob = json.dumps(ctx, ensure_ascii=False)
preview = blob if len(blob) <= 600 else blob[:600] + "…"
return {
"note": (
"Synthetic / client-built context_json — Assistent HTTP API does not "
"return compactContext"
),
"keys": sorted(ctx.keys()),
"sizes": sizes,
"chars": len(blob),
"token_ish": round(len(blob) / 4), # rough chars/4
"preview": preview,
}
def resolve_exact_profile_defaults(
exact: dict[str, Any] | None,
*,
profile_name: str | None = None,
) -> dict[str, Any]:
"""Mirror SA.session.resolveExactProfileDefaults."""
exact = exact if isinstance(exact, dict) else {}
gen = exact.get("generation") if isinstance(exact.get("generation"), dict) else {}
profiles = exact.get("profiles") if isinstance(exact.get("profiles"), dict) else {}
profile = profile_name or gen.get("profile") or "turbo"
from_profile = profiles.get(profile) if isinstance(profiles.get(profile), dict) else {}
return {
"profile": profile,
"steps": from_profile.get("steps", gen.get("steps")),
"cfg": from_profile.get("cfg", gen.get("cfg")),
"sigma_shift": from_profile.get("sigma_shift", gen.get("sigma_shift")),
}
def analyze_exact_merge(
patch: dict[str, Any] | None,
*,
recommended: dict[str, Any] | None = None,
krea_profile: str | None = None,
session_exact: dict[str, Any] | None = None,
user_param_intent: bool = False,
exact: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Simulate client mergeExactParamsForGenerate for diagnostics."""
patch = patch if isinstance(patch, dict) else None
acts = patch.get("actions") if patch else None
wants_gen = bool(
patch
and (
patch.get("generate") is True
or (isinstance(acts, list) and "generate" in [str(a) for a in acts])
)
)
defaults = resolve_exact_profile_defaults(exact, profile_name=krea_profile)
if recommended and isinstance(recommended, dict):
for k in EXACT_GENERATE_PARAM_KEYS:
if defaults.get(k) is None and recommended.get(k) is not None:
defaults[k] = recommended[k]
if not krea_profile and recommended.get("profile"):
defaults["profile"] = recommended.get("profile")
present = {
k: patch.get(k)
for k in EXACT_GENERATE_PARAM_KEYS
if patch and patch.get(k) is not None
}
missing = [k for k in EXACT_GENERATE_PARAM_KEYS if k not in present]
would_fill: dict[str, Any] = {}
would_force: dict[str, Any] = {}
hints: list[str] = []
if not wants_gen:
return {
"wants_generate": False,
"patch_params": present,
"missing_exact_keys": missing,
"recommended": {
k: defaults.get(k) for k in EXACT_GENERATE_PARAM_KEYS + ("profile",)
},
"would_fill": {},
"would_force_to_exact": {},
"hints": ["no generate in patch — Exact merge not applied"],
}
merged = dict(patch or {})
for key in EXACT_GENERATE_PARAM_KEYS:
if merged.get(key) is not None:
if (
not user_param_intent
and defaults.get(key) is not None
and str(merged[key]) != str(defaults[key])
):
would_force[key] = {"from": merged[key], "to": defaults[key]}
merged[key] = defaults[key]
hints.append(
f"{key}={would_force[key]['from']} ≠ Exact {defaults['profile']} "
f"{defaults[key]} — client would force Exact (0.14.1)"
)
continue
if user_param_intent and isinstance(session_exact, dict) and session_exact.get(key) is not None:
would_fill[key] = session_exact[key]
merged[key] = session_exact[key]
hints.append(f"{key} omitted — would fill from session_exact")
continue
if defaults.get(key) is not None:
would_fill[key] = defaults[key]
merged[key] = defaults[key]
hints.append(
f"{key} omitted — client would fill Exact {defaults.get('profile')}={defaults[key]}"
)
return {
"wants_generate": True,
"patch_params": present,
"missing_exact_keys": missing,
"recommended": {
k: defaults.get(k) for k in EXACT_GENERATE_PARAM_KEYS + ("profile",)
},
"would_fill": would_fill,
"would_force_to_exact": would_force,
"merged_preview": {
k: merged.get(k) for k in EXACT_GENERATE_PARAM_KEYS if merged.get(k) is not None
},
"hints": hints,
}
def _default_context(
*,
persona: str,
pack: str,
exact: dict[str, Any] | None = None,
krea_profile: str | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
profile = krea_profile or "turbo"
defaults = resolve_exact_profile_defaults(exact, profile_name=profile)
ctx: dict[str, Any] = {
"session": True,
"persona": persona,
"pack": pack,
"debug_eval": True,
"has_vision_image": False,
"images_in_request": False,
"krea_profile": defaults.get("profile") or profile,
"recommended_params": {
"steps": defaults.get("steps"),
"cfg": defaults.get("cfg"),
"sigma_shift": defaults.get("sigma_shift"),
},
"architecture_ok": True,
}
if extra:
ctx.update(extra)
return ctx
def _compact_get_config(data: dict[str, Any]) -> dict[str, Any]:
exact = data.get("exact") if isinstance(data.get("exact"), dict) else {}
skills = data.get("skills") if isinstance(data.get("skills"), list) else []
enabled = data.get("enabled_skills") if isinstance(data.get("enabled_skills"), list) else []
profiles = exact.get("profiles") if isinstance(exact.get("profiles"), dict) else {}
return {
"persona": data.get("persona"),
"default_persona": data.get("default_persona"),
"skills": [
(s.get("id") if isinstance(s, dict) else s) for s in skills[:30]
],
"enabled_skills": [str(x) for x in enabled[:30]],
"exact_generation": exact.get("generation")
if isinstance(exact.get("generation"), dict)
else None,
"exact_profiles": {
name: {
k: (profiles[name] or {}).get(k)
for k in EXACT_GENERATE_PARAM_KEYS
}
for name in list(profiles.keys())[:8]
if isinstance(profiles.get(name), dict)
},
"keys": sorted(data.keys())[:40],
}
def _probe_config(base: str, sid: str, persona: str) -> dict[str, Any]:
ok, data, ms = _http_json(
f"{base}/API/AssistentGetConfig",
method="POST",
body={"session_id": sid, "persona": persona},
timeout=25.0,
)
out: dict[str, Any] = {"ok": False, "ms": round(ms, 1), "error": None}
if not ok:
out["error"] = str(data)[:300]
return out
if isinstance(data, dict) and data.get("error"):
out["error"] = str(data.get("error"))[:300]
return out
if isinstance(data, dict):
out["ok"] = True
out["data"] = _compact_get_config(data)
out["_exact"] = data.get("exact") if isinstance(data.get("exact"), dict) else {}
return out
out["error"] = f"unexpected GetConfig body: {str(data)[:200]}"
return out
def build_chat_trace(
*,
ok: bool,
timings_ms: dict[str, float],
model: str | None,
preferred: str | None,
persona: str,
pack: str,
context: dict[str, Any],
skills: list[str],
config_probe: dict[str, Any] | None,
reply: str | None,
response: dict[str, Any] | None,
errors: list[str],
warnings: list[str],
hints: list[str],
) -> dict[str, Any]:
extracted = extract_assistent_patch(reply)
patch = extracted.get("patch")
exact = None
enabled_skills: list[str] = []
if isinstance(config_probe, dict):
exact = config_probe.get("_exact")
data = config_probe.get("data") or {}
if isinstance(data.get("enabled_skills"), list):
enabled_skills = [str(x) for x in data["enabled_skills"]]
recommended = context.get("recommended_params")
if not isinstance(recommended, dict):
recommended = None
exact_merge = analyze_exact_merge(
patch,
recommended=recommended,
krea_profile=context.get("krea_profile")
if isinstance(context.get("krea_profile"), str)
else None,
session_exact=context.get("session_exact")
if isinstance(context.get("session_exact"), dict)
else None,
exact=exact if isinstance(exact, dict) else None,
)
ask = None
if isinstance(patch, dict) and patch.get("ask") is not None:
ask = patch.get("ask")
soft_errors = list(errors)
if isinstance(response, dict):
blob = json.dumps(response, ensure_ascii=False)
if "sqlite" in blob.lower() or "SaveChat" in blob:
soft_errors.append("response mentions Sqlite/SaveChat — chat text may still be OK")
system_layers = None
if isinstance(response, dict) and isinstance(response.get("system_layers"), dict):
system_layers = response.get("system_layers")
raw_preview = None
if isinstance(response, dict):
raw = response.get("raw")
if isinstance(raw, dict):
raw_preview = {k: raw[k] for k in list(raw.keys())[:12]}
elif raw is not None:
raw_preview = str(raw)[:400]
out_hints = list(hints) + list(exact_merge.get("hints") or [])
out: dict[str, Any] = {
"ok": ok,
"timings_ms": timings_ms,
"model": model,
"preferred": preferred,
"persona": persona,
"pack": pack,
"krea_profile": context.get("krea_profile"),
"checkpoint": context.get("checkpoint"),
"recommended_params": recommended
or exact_merge.get("recommended"),
"compact_context": summarize_compact_context(context),
"skills": {
"requested": list(skills),
"enabled_from_config": enabled_skills,
"note": "skills[] on AssistentChat selects overlays; catalog from GetConfig",
},
"hops": {
"ask_from_patch": ask,
"note": (
"HTTP AssistentChat has no hop list; ask:settings/inventory run "
"server-side when patch.ask requests them (final reply only)"
),
},
"system_chars": response.get("system_chars") if isinstance(response, dict) else None,
"system_layers": system_layers,
"prompt_eval_count": response.get("prompt_eval_count")
if isinstance(response, dict)
else None,
"reply": reply,
"reply_prose": extracted.get("prose"),
"patch": _summarize_patch(patch),
"exact_merge": exact_merge,
"park_warm": {
"invoked": False,
"note": "AssistentParkLlm / AssistentWarmLlm are Generate-path; not called here",
},
"raw": raw_preview,
"config_probe_ok": bool(config_probe and config_probe.get("ok")),
"errors": soft_errors,
"warnings": list(warnings),
"hints": out_hints,
"gaps": list(_TRACE_GAPS),
}
if isinstance(response, dict) and response.get("civitai_results"):
out["civitai_results"] = response.get("civitai_results")
return out
def create_debug_session(
cfg: Config,
*,
persona: str | None = None,
pack: str | None = None,
model: str | None = None,
context: dict[str, Any] | None = None,
skills: list[str] | None = None,
probe_config: bool = True,
) -> dict[str, Any]:
"""Start a debug session: GetNewSession + optional GetConfig probe."""
store = get_session_store()
store.purge_expired()
persona_id = (persona or "").strip() or "neutral"
pack_name = (pack or "").strip() or "ordinary"
skill_ids = [str(s) for s in (skills or []) if s]
warnings: list[str] = [
"opt-in Assistent session — may load VRAM; may write sqlite chat if UI/API persists",
"not included in /snapshot",
f"TTL {SESSION_TTL_SEC // 60} min idle; max {MAX_SESSIONS} sessions; "
f"message cap {MAX_MESSAGE_CHARS} chars",
]
t0 = time.perf_counter()
base, via = _swarm_base(cfg)
ms_sess = 0.0
swarm_sid: str | None = None
err: str | None = None
config_probe: dict[str, Any] | None = None
exact: dict[str, Any] | None = None
if base:
swarm_sid, err, ms_sess = _session_id(base)
if not swarm_sid:
return {
"ok": False,
"error": f"GetNewSession failed: {err}",
"via": via,
"ms": round((time.perf_counter() - t0) * 1000, 1),
"warnings": warnings,
}
chosen, preferred = _resolve_chat_model(cfg, base, swarm_sid, model=model)
if probe_config:
config_probe = _probe_config(base, swarm_sid, persona_id)
if config_probe.get("_exact"):
exact = config_probe["_exact"]
if not config_probe.get("ok"):
warnings.append(
f"AssistentGetConfig probe failed: {config_probe.get('error')}"
)
else:
# No local tunnel — session still usable for message bookkeeping; chat uses SSH.
via = "ssh"
chosen, preferred = _resolve_chat_model(cfg, None, None, model=model)
warnings.append(
"no local Swarm tunnel — chat turns will use SSH :7801; "
"GetNewSession deferred to first chat"
)
if not chosen and model:
chosen = model.strip() or None
ctx = _default_context(
persona=persona_id,
pack=pack_name,
exact=exact,
krea_profile=(context or {}).get("krea_profile") if context else None,
extra=context if isinstance(context, dict) else None,
)
sid = str(uuid.uuid4())
session = DebugChatSession(
debug_session_id=sid,
swarm_session_id=swarm_sid,
persona=persona_id,
pack=pack_name,
model=chosen,
preferred=preferred,
via=via,
context=ctx,
skills=skill_ids,
config_probe=config_probe,
warnings=warnings,
)
store.put(session)
return {
"ok": True,
"debug_session_id": sid,
"swarm_session_id": swarm_sid,
"persona": persona_id,
"pack": pack_name,
"model": chosen,
"preferred": preferred,
"via": via,
"session_ms": round(ms_sess, 1),
"ms": round((time.perf_counter() - t0) * 1000, 1),
"context_keys": sorted(ctx.keys()),
"skills": skill_ids,
"config_probe": {
"ok": bool(config_probe and config_probe.get("ok")),
"ms": (config_probe or {}).get("ms"),
"data": (config_probe or {}).get("data"),
"error": (config_probe or {}).get("error"),
}
if config_probe
else None,
"warnings": warnings,
"ttl_sec": SESSION_TTL_SEC,
"gaps": list(_TRACE_GAPS),
"session": session.summary(),
}
def get_debug_session(session_id: str) -> dict[str, Any]:
store = get_session_store()
s = store.get(session_id)
if s is None:
return {"ok": False, "error": "session not found or expired"}
return {
"ok": True,
"session": s.summary(),
"messages": [
{"role": m["role"], "content_chars": len(m.get("content") or "")}
for m in s.messages
],
"context": summarize_compact_context(s.context),
"last_trace": s.last_trace,
"gaps": list(_TRACE_GAPS),
}
def delete_debug_session(session_id: str) -> dict[str, Any]:
ok = get_session_store().delete(session_id)
return {"ok": ok, "deleted": ok, "debug_session_id": session_id}
def chat_debug_session(
cfg: Config,
session_id: str,
*,
message: str | None = None,
timeout: float | int | str | None = None,
context: dict[str, Any] | None = None,
pack: str | None = None,
model: str | None = None,
skills: list[str] | None = None,
persona: str | None = None,
) -> dict[str, Any]:
"""Send one user turn; return reply + trace."""
store = get_session_store()
session = store.get(session_id)
if session is None:
return {"ok": False, "error": "session not found or expired"}
text, truncated = clamp_message(message)
if not text:
return {"ok": False, "error": "message required", "debug_session_id": session_id}
warnings = list(session.warnings)
if truncated:
warnings.append(f"message truncated to {MAX_MESSAGE_CHARS} chars")
if pack and pack.strip():
session.pack = pack.strip()
if persona and persona.strip():
session.persona = persona.strip()
if model and str(model).strip():
session.model = str(model).strip()
if skills is not None:
session.skills = [str(s) for s in skills if s]
if isinstance(context, dict) and context:
session.context.update(context)
session.context["persona"] = session.persona
session.context["pack"] = session.pack
t_chat = _clamp_chat_eval_timeout(timeout)
t0 = time.perf_counter()
timings: dict[str, float] = {}
# Trim history
session.messages.append({"role": "user", "content": text})
if len(session.messages) > MAX_HISTORY_TURNS * 2:
session.messages = session.messages[-(MAX_HISTORY_TURNS * 2) :]
base, via = _swarm_base(cfg)
if via == "local":
session.via = "local"
errors: list[str] = []
reply: str | None = None
response: dict[str, Any] | None = None
ms_call = 0.0
ms_sess = 0.0
ok = False
if base:
if not session.swarm_session_id:
sid, err, ms_sess = _session_id(base)
timings["session"] = round(ms_sess, 1)
if not sid:
session.messages.pop() # rollback user msg
return {
"ok": False,
"debug_session_id": session_id,
"error": f"GetNewSession failed: {err}",
"warnings": warnings,
}
session.swarm_session_id = sid
else:
timings["session"] = 0.0
if not session.model:
chosen, preferred = _resolve_chat_model(
cfg, base, session.swarm_session_id, model=None
)
session.model = chosen
session.preferred = preferred or session.preferred
if not session.model:
session.messages.pop()
return {
"ok": False,
"debug_session_id": session_id,
"error": "no model (AssistentListModels.preferred / default_chat)",
"hints": ["GET /assistent/roles + /ollama — нет preferred chat model"],
"warnings": warnings,
}
if session.config_probe is None:
probe = _probe_config(base, session.swarm_session_id, session.persona)
session.config_probe = probe
timings["config_probe"] = float(probe.get("ms") or 0)
if probe.get("_exact") and not session.context.get("recommended_params"):
defaults = resolve_exact_profile_defaults(
probe["_exact"],
profile_name=session.context.get("krea_profile"),
)
session.context["recommended_params"] = {
"steps": defaults.get("steps"),
"cfg": defaults.get("cfg"),
"sigma_shift": defaults.get("sigma_shift"),
}
session.context.setdefault("krea_profile", defaults.get("profile"))
payload = {
"session_id": session.swarm_session_id,
"baseUrl": "http://127.0.0.1:11434",
"model": session.model,
"pack": session.pack,
"persona": session.persona,
"includeBase": True,
"messages": list(session.messages),
"context_json": json.dumps(session.context, ensure_ascii=False),
"skills": list(session.skills),
}
ok_http, data, ms_call = _http_json(
f"{base}/API/AssistentChat",
method="POST",
body=payload,
timeout=t_chat,
)
timings["chat"] = round(ms_call, 1)
if not ok_http:
errors.append(str(data)[:400])
response = None
ok = False
elif isinstance(data, dict):
response = data
if data.get("error"):
err_s = str(data.get("error"))[:400]
# Soft-fail Sqlite: keep reply if present
reply = data.get("reply")
if reply is not None:
reply = str(reply)
if reply:
errors.append(err_s)
warnings.append(
"AssistentChat returned error but also reply — treating as soft fail"
)
ok = True
else:
errors.append(err_s)
ok = False
else:
reply = data.get("reply")
if reply is not None:
reply = str(reply)
ok = reply is not None
if data.get("model"):
session.model = str(data.get("model"))
else:
errors.append(f"unexpected AssistentChat body: {str(data)[:200]}")
ok = False
else:
# Full message history over SSH :7801 when local tunnel is down.
session.via = "ssh"
finish = _assistent_chat_via_ssh_messages(
cfg,
messages=list(session.messages),
persona=session.persona,
pack=session.pack,
model=session.model,
context=session.context,
skills=session.skills,
timeout=t_chat,
swarm_session_id=session.swarm_session_id,
t0=t0,
)
if finish.get("swarm_session_id"):
session.swarm_session_id = finish["swarm_session_id"]
if finish.get("model"):
session.model = finish["model"]
if finish.get("preferred"):
session.preferred = finish["preferred"]
timings["session"] = float(finish.get("session_ms") or 0)
timings["chat"] = float(finish.get("chat_ms") or 0)
reply = finish.get("reply")
ok = bool(finish.get("ok") and reply is not None)
errors.extend(finish.get("errors") or [])
if finish.get("error") and finish["error"] not in errors:
errors.append(str(finish["error"]))
response = {
"reply": reply,
"system_chars": finish.get("system_chars"),
"system_layers": finish.get("system_layers"),
"prompt_eval_count": finish.get("prompt_eval_count"),
"raw": finish.get("raw"),
"model": finish.get("model"),
}
timings["total"] = round((time.perf_counter() - t0) * 1000, 1)
if reply:
session.messages.append({"role": "assistant", "content": reply})
session.turn_count += 1
else:
# rollback user message on hard failure
if session.messages and session.messages[-1].get("role") == "user":
session.messages.pop()
hints: list[str] = []
if any("sqlite" in e.lower() for e in errors):
hints.append(
"Sqlite — Microsoft.Data.Sqlite рядом с extension DLL (seed-extensions ≥0.13.1)"
)
if any("timeout" in e.lower() or "timed out" in e.lower() for e in errors):
hints.append(f"timeout {t_chat:.0f}s — увеличь timeout (cap {MAX_CHAT_EVAL_TIMEOUT:.0f})")
trace = build_chat_trace(
ok=ok,
timings_ms=timings,
model=session.model,
preferred=session.preferred,
persona=session.persona,
pack=session.pack,
context=session.context,
skills=session.skills,
config_probe=session.config_probe,
reply=reply,
response=response if isinstance(response, dict) else None,
errors=errors,
warnings=warnings,
hints=hints,
)
session.last_trace = trace
session.touch()
return {
"ok": ok,
"debug_session_id": session_id,
"swarm_session_id": session.swarm_session_id,
"turn_count": session.turn_count,
"message": text,
"reply": reply,
"trace": trace,
"session": session.summary(),
"timeout_sec": t_chat,
"via": session.via,
"error": errors[0] if errors and not ok else None,
"errors": errors,
"warnings": warnings,
"hints": hints + list(trace.get("hints") or []),
}
def _assistent_chat_via_ssh_messages(
cfg: Config,
*,
messages: list[dict[str, str]],
persona: str,
pack: str,
model: str | None,
context: dict[str, Any],
skills: list[str],
timeout: float,
swarm_session_id: str | None,
t0: float,
) -> dict[str, Any]:
"""Multi-turn AssistentChat over SSH when local tunnel is down."""
from gpu_rent import debug_checks
from gpu_rent.state import load_state
state = load_state()
if not debug_checks.ssh_ready(cfg, state):
return {
"ok": False,
"error": debug_checks.SSH_UNAVAILABLE,
"errors": [debug_checks.SSH_UNAVAILABLE],
"ms": round((time.perf_counter() - t0) * 1000, 1),
}
host = debug_checks.ssh_host(state)
assert host is not None
chosen = model
preferred = None
if not chosen:
from gpu_rent.debug_assistent import collect_assistent_roles
roles = collect_assistent_roles(cfg)
preferred = (roles.get("roles") or {}).get("default_chat")
if isinstance(preferred, str) and preferred.strip():
chosen = preferred.strip()
script = f'''
import json, urllib.request, time
MESSAGES = {json.dumps(messages, ensure_ascii=False)}
PERSONA = {json.dumps(persona, ensure_ascii=False)}
PACK = {json.dumps(pack, ensure_ascii=False)}
MODEL = {json.dumps(chosen or "", ensure_ascii=False)}
CONTEXT = {json.dumps(context, ensure_ascii=False)}
SKILLS = {json.dumps(skills, ensure_ascii=False)}
SID = {json.dumps(swarm_session_id or "", ensure_ascii=False)}
TIMEOUT = {float(timeout)}
def post(path, payload, timeout=TIMEOUT):
t0 = time.time()
req = urllib.request.Request(
"http://127.0.0.1:7801" + path,
data=json.dumps(payload).encode(),
headers={{"Content-Type": "application/json"}},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
ms = (time.time() - t0) * 1000
try:
data = json.loads(raw)
except Exception:
data = raw[:500]
return True, data, ms
except Exception as e:
return False, str(e), (time.time() - t0) * 1000
ms_sess = 0
sid = SID.strip()
if not sid:
ok, sess, ms_sess = post("/API/GetNewSession", {{}}, timeout=20)
if not ok or not isinstance(sess, dict) or not sess.get("session_id"):
print(json.dumps({{"ok": False, "error": sess, "session_ms": ms_sess, "via": "ssh"}}))
raise SystemExit(0)
sid = sess["session_id"]
preferred = None
model = MODEL.strip()
if not model:
cok, mdata, _ = post("/API/AssistentListModels", {{
"session_id": sid, "baseUrl": "http://127.0.0.1:11434"
}}, timeout=25)
if cok and isinstance(mdata, dict):
preferred = mdata.get("preferred")
model = (preferred or "").strip()
if not model:
print(json.dumps({{
"ok": False, "error": "no model", "session_ms": ms_sess, "via": "ssh",
"preferred": preferred, "swarm_session_id": sid
}}))
raise SystemExit(0)
payload = {{
"session_id": sid,
"baseUrl": "http://127.0.0.1:11434",
"model": model,
"pack": PACK,
"persona": PERSONA,
"includeBase": True,
"messages": MESSAGES,
"context_json": json.dumps(CONTEXT, ensure_ascii=False),
"skills": SKILLS,
}}
cok, data, cms = post("/API/AssistentChat", payload, timeout=TIMEOUT)
print(json.dumps({{
"ok": cok, "data": data, "chat_ms": cms, "session_ms": ms_sess,
"via": "ssh", "model": model, "preferred": preferred or model,
"swarm_session_id": sid
}}, ensure_ascii=False))
'''
try:
from gpu_rent.ssh_ops import run_ssh
out = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + script + "\nPY",
check=False,
timeout=int(timeout) + 40,
).strip()
remote = json.loads(out.splitlines()[-1])
except Exception as exc:
return {
"ok": False,
"error": str(exc)[:240],
"errors": [str(exc)[:240]],
"ms": round((time.perf_counter() - t0) * 1000, 1),
}
data = remote.get("data")
ok = bool(remote.get("ok"))
finished = _finish_chat_eval(
ok=ok,
data=data if ok or isinstance(data, dict) else remote.get("error") or data,
ms_call=float(remote.get("chat_ms") or 0),
ms_total=(time.perf_counter() - t0) * 1000,
via="ssh",
session_ms=float(remote.get("session_ms") or 0),
message=messages[-1]["content"] if messages else "",
persona=persona,
pack=pack,
model=remote.get("model") or chosen,
preferred=remote.get("preferred") or preferred,
hints=[],
errors=[],
timeout=timeout,
)
finished["swarm_session_id"] = remote.get("swarm_session_id")
finished["chat_ms"] = remote.get("chat_ms")
finished["session_ms"] = remote.get("session_ms")
if isinstance(data, dict):
for k in ("system_chars", "system_layers", "prompt_eval_count"):
if k in data:
finished[k] = data[k]
return finished
def run_assistent_chat_eval_via_session(
cfg: Config,
*,
message: str | None = None,
persona: str | None = None,
pack: str | None = None,
model: str | None = None,
timeout: float | int | str | None = None,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""One-shot convenience: create session → one chat → delete session."""
text = (message or DEFAULT_CHAT_EVAL_MESSAGE).strip() or DEFAULT_CHAT_EVAL_MESSAGE
created = create_debug_session(
cfg,
persona=persona,
pack=pack,
model=model,
context=context,
probe_config=True,
)
if not created.get("ok"):
return {
**created,
"message": text,
"persona": (persona or "").strip() or "neutral",
"pack": (pack or "").strip() or "ordinary",
}
sid = created["debug_session_id"]
try:
result = chat_debug_session(
cfg,
sid,
message=text,
timeout=timeout,
model=model,
pack=pack,
persona=persona,
)
finally:
delete_debug_session(sid)
# Flatten for backward-compatible chat-eval consumers + nested trace.
trace = result.get("trace") or {}
return {
"ok": result.get("ok"),
"via": result.get("via") or created.get("via"),
"ms": (trace.get("timings_ms") or {}).get("total"),
"chat_ms": (trace.get("timings_ms") or {}).get("chat"),
"session_ms": created.get("session_ms")
or (trace.get("timings_ms") or {}).get("session"),
"timeout_sec": result.get("timeout_sec"),
"message": text,
"persona": result.get("session", {}).get("persona")
or created.get("persona"),
"pack": result.get("session", {}).get("pack") or created.get("pack"),
"model": trace.get("model") or created.get("model"),
"preferred": trace.get("preferred") or created.get("preferred"),
"reply": result.get("reply"),
"reply_prose": trace.get("reply_prose"),
"patch": trace.get("patch"),
"raw": trace.get("raw"),
"system_chars": trace.get("system_chars"),
"system_layers": trace.get("system_layers"),
"prompt_eval_count": trace.get("prompt_eval_count"),
"trace": trace,
"errors": result.get("errors") or [],
"hints": result.get("hints") or [],
"warnings": result.get("warnings") or created.get("warnings") or [],
"error": result.get("error"),
"gaps": list(_TRACE_GAPS),
"note": "chat-eval wraps POST /assistent/session + one /chat + DELETE",
}
def collect_assistent_diagnose(
cfg: Config,
*,
chat_smoke: bool = False,
include_logs: bool = True,
) -> dict[str, Any]:
"""Full static+live Assistent health bundle (alias of deep collect)."""
deep = collect_assistent_deep(
cfg, chat_smoke=chat_smoke, include_logs=include_logs
)
deep["playbook"] = {
**(deep.get("playbook") or {}),
"session": (
"POST /assistent/session → POST /assistent/session/{id}/chat "
"(multi-turn + trace) → DELETE when done"
),
"chat_eval": "POST /assistent/chat-eval (one-shot session wrapper)",
"diagnose": "GET /assistent/diagnose (this bundle; also GET /assistent?logs=1)",
}
deep["session_store"] = {
"active": len(get_session_store().list_ids()),
"ttl_sec": SESSION_TTL_SEC,
"max_sessions": MAX_SESSIONS,
"max_message_chars": MAX_MESSAGE_CHARS,
}
deep["gaps"] = list(_TRACE_GAPS)
return deep