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>
1164 lines
41 KiB
Python
1164 lines
41 KiB
Python
"""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
|