hermes-hub/src/antigravity_provider/router/local_supervisor.py
Hermes Team 6f4394113b review(a56): сжатие контекста принято с исправлениями
Проверено исполнением на сервере владельца, а не по отчёту.

Работает. Сжатие вызывается в настоящем пути запроса (local_adapter.py:168) —
разрыв, ради которого писалось задание, закрыт. Замер на живом компрессоре:
5667 токенов на входе, 624 на выходе, 0,11x, экономия 5043 токена за 127,6 с.

Исправлено четыре дефекта.

1. /props и /tokenize запрашивались по адресу с суффиксом /v1. У llama.cpp они
   живут в корне: измерено, /props → 200, /v1/props → 404, то же с /tokenize.
   Адаптер передаёт супервизору именно адрес с /v1, поэтому счёт токенов молча
   падал на посимвольную оценку, и порог сжатия считался от выдуманного числа.
   Адрес нормализуется.

2. Заявленные «сто процентов сохранения фактов» модель не даёт. Замер: 37 из 38,
   97,4%, потерян 001cd1f. Сто процентов получались дописыванием недостающих
   фактов списком — механизм верный, но измеренное число подменялось
   исправленным, а «(100%)» было вписано в сообщение текстом. Теперь полнота
   самой модели сохраняется отдельно и показывается владельцу: иначе ухудшение
   модели осталось бы незамеченным.

3. Итог сжатия считался посимвольно (длина / 3.5) и подавался рядом с настоящим
   числом токенов на входе. Теперь пересчитывается токенизатором сервера, а
   недоступность токенизатора помечается признаком оценки.

4. Порт 8082 был зашит запасным адресом в двух местах вопреки прямому запрету в
   задании. Профиль без адреса теперь даёт состояние «не настроен» с причиной,
   а не молчаливый стук в 8082.

Не выполнено исполнителем: проверка на живом сервере (P0-6.1). Тест на неё
пропускается как негерметичный, замеры выше сделал ревьюер.

649 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:32:35 +07:00

514 lines
23 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.

"""Hermes Hub Local Model Supervisor (Надзиратель локальных моделей).
Implements P0-4, P0-5, P0-6, P0-7, P0-8 for Task A52:
- P0-4: Automated supervisor role for local providers (local, llama.cpp, ollama, vllm)
- P0-5: Measured context limits via /props and exact token counting via /tokenize
- P0-6: Semantic task splitting across file/class/function boundaries with sequential delivery
- P0-7: Execution monitoring, distinguishing SUCCESS, TIMEOUT, ERROR, and REASONING_EXHAUSTED (A39)
- P0-8: Shared memory tracking in AI-Memory by GGUF build metadata
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .context_compressor import (
COMPRESSION_MEMORY_FILE,
CompressionOutcome,
ContextCompressor,
FactualEntities,
extract_factual_entities,
verify_facts_retention,
)
logger = logging.getLogger(__name__)
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
LOCAL_MEMORY_FILE = SHARED_MEMORY_VAULT / "01_PROJECTS" / "hermes-hub" / "local_models_memory.json"
class SupervisorOutcome(str, Enum):
SUCCESS = "SUCCESS"
TIMEOUT = "TIMEOUT"
ERROR = "ERROR"
REASONING_EXHAUSTED = "REASONING_EXHAUSTED"
class IndivisibleTaskError(Exception):
"""Raised when a single code block cannot be semantically split and exceeds model context."""
pass
class ContextExhaustedError(Exception):
"""Raised when maximum split retries are exhausted without successful generation."""
pass
@dataclass
class TokenCountResult:
tokens_count: int
is_estimated: bool
method: str # "tokenize_api" or "char_heuristic"
@dataclass
class ServerPropsResult:
n_ctx: int
total_slots: int
model_name: str
model_path: str
is_measured: bool
@dataclass
class ModelMemoryRecord:
gguf_name: str
safe_chunk_tokens: int
max_tested_tokens: int
successful_dispatches: int
failed_dispatches: int
last_working_context: int
avg_generation_tps: float
last_updated: str
history: List[Dict[str, Any]] = field(default_factory=list)
# Разделитель между сообщениями при подсчёте объёма контекста.
MESSAGE_SEPARATOR = "\n\n"
class LocalSupervisor:
"""Oversees and regulates work dispatch to local models."""
DEFAULT_SAFETY_MARGIN_TOKENS: int = 1024
DEFAULT_RESPONSE_MARGIN_TOKENS: int = 4096
MAX_SPLIT_ATTEMPTS: int = 3
def __init__(
self,
base_url: str = "http://127.0.0.1:8081",
memory_path: Optional[Path] = None,
compressor: Optional[ContextCompressor] = None,
):
self.base_url = base_url.rstrip("/")
# /props и /tokenize у llama.cpp живут в КОРНЕ, а не под /v1. Проверено
# на сервере владельца: /props → 200, /v1/props → 404; то же с
# /tokenize. Адаптер передаёт сюда адрес вида .../v1, поэтому без
# нормализации оба запроса получали 404, счёт токенов молча падал на
# посимвольную оценку, и порог сжатия считался от выдуманного числа.
self.root_url = self.base_url[:-3].rstrip("/") if self.base_url.endswith("/v1") else self.base_url
self.memory_path = memory_path or LOCAL_MEMORY_FILE
self.compressor = compressor or ContextCompressor()
# -------------------------------------------------------------
# P0-5: Measured limits via /props and /tokenize
# -------------------------------------------------------------
def query_server_props(self, timeout_sec: float = 3.0) -> ServerPropsResult:
"""Query real model properties and context limits from live server."""
props_url = f"{self.root_url}/props"
try:
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-LocalSupervisor/1.0"})
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
data = json.loads(resp.read().decode("utf-8"))
gen_settings = data.get("default_generation_settings", {})
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 65536)
total_slots = int(data.get("total_slots", 1))
model_path = str(data.get("model_path") or data.get("model_alias") or "")
# Extract clean GGUF model name
model_name = Path(model_path).stem if model_path else "local-model"
return ServerPropsResult(
n_ctx=n_ctx,
total_slots=total_slots,
model_name=model_name,
model_path=model_path,
is_measured=True,
)
except Exception as err:
logger.warning("Failed to query /props from %s: %s (using unverified fallback)", props_url, err)
return ServerPropsResult(
n_ctx=65536,
total_slots=1,
model_name="local-model-unverified",
model_path="",
is_measured=False,
)
def count_tokens(self, text: str, timeout_sec: float = 3.0) -> TokenCountResult:
"""Count tokens accurately via /tokenize endpoint with fallback character heuristic."""
if not text:
return TokenCountResult(tokens_count=0, is_estimated=False, method="exact_empty")
tok_url = f"{self.root_url}/tokenize"
try:
payload = json.dumps({"content": text}).encode("utf-8")
req = urllib.request.Request(
tok_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
data = json.loads(resp.read().decode("utf-8"))
tokens = data.get("tokens", [])
return TokenCountResult(
tokens_count=len(tokens),
is_estimated=False,
method="tokenize_api",
)
except Exception as err:
logger.warning("Failed to /tokenize with %s: %s. Using heuristic estimate.", tok_url, err)
# Standard heuristic for mixed code/russian/english: ~3.5 chars per token
est_tokens = max(1, int(len(text) / 3.5))
return TokenCountResult(
tokens_count=est_tokens,
is_estimated=True,
method="char_heuristic",
)
# -------------------------------------------------------------
# P0-6: Semantic Task Splitting
# -------------------------------------------------------------
def calculate_effective_prompt_limit(
self,
server_n_ctx: int,
expected_response_tokens: int = DEFAULT_RESPONSE_MARGIN_TOKENS,
safety_margin_tokens: int = DEFAULT_SAFETY_MARGIN_TOKENS,
model_name: Optional[str] = None,
) -> int:
"""Calculate safe prompt limit considering response budget, safety buffer, and past memory."""
base_limit = max(1024, server_n_ctx - expected_response_tokens - safety_margin_tokens)
# Check if memory has a smaller known safe working volume
if model_name:
rec = self.get_model_memory(model_name)
if rec and rec.safe_chunk_tokens > 0 and rec.safe_chunk_tokens < base_limit:
return rec.safe_chunk_tokens
return base_limit
def split_task_semantically(
self,
task_text: str,
max_chunk_tokens: int,
prompt_template: str = "",
) -> List[str]:
"""Split a code or textual task across semantic boundaries (file, class, function, markdown).
If the entire text fits, returns [task_text].
If an individual unit is indivisible and exceeds limit, raises IndivisibleTaskError.
"""
template_tokens = self.count_tokens(prompt_template).tokens_count if prompt_template else 0
usable_budget = max(1, max_chunk_tokens - template_tokens)
full_count = self.count_tokens(task_text).tokens_count
if full_count <= usable_budget:
return [task_text]
# Semantic splitting strategy:
# Step 1: Detect File Boundaries (e.g. diffs, markdown files, --- file: ...)
file_splits = re.split(r"(?=(?:^|\n)\s*(?:diff --git|--- [a-zA-Z0-9_/.-]+|### File:|```[a-zA-Z0-9_-]+\s*# [a-zA-Z0-9_/.-]+))", task_text)
file_splits = [s for s in file_splits if s.strip()]
if len(file_splits) > 1 and all(self.count_tokens(f).tokens_count <= usable_budget for f in file_splits):
return self._pack_chunks(file_splits, usable_budget)
# Step 2: Detect Code Function/Class Boundaries
# Match class/def/function/sections (including indented methods)
code_units = []
for segment in (file_splits if len(file_splits) > 1 else [task_text]):
seg_tokens = self.count_tokens(segment).tokens_count
if seg_tokens <= usable_budget and len(file_splits) > 1:
code_units.append(segment)
else:
sub_splits = re.split(r"(?=(?:^|\n)\s*(?:class\s+[A-Za-z0-9_]+|def\s+[A-Za-z0-9_]+|async\s+def\s+[A-Za-z0-9_]+|function\s+[A-Za-z0-9_]+|##+\s+))", segment)
sub_splits = [s for s in sub_splits if s.strip()]
if len(sub_splits) > 1:
for sub in sub_splits:
if self.count_tokens(sub).tokens_count > usable_budget:
para_splits = re.split(r"(?=\n\n+)", sub)
para_splits = [p for p in para_splits if p.strip()]
for p in para_splits:
if self.count_tokens(p).tokens_count > usable_budget:
raise IndivisibleTaskError(
f"Неделимый фрагмент ({self.count_tokens(p).tokens_count} токенов) превышает лимит ({usable_budget} токенов)."
)
code_units.append(p)
else:
code_units.append(sub)
else:
para_splits = re.split(r"(?=\n\n+)", segment)
para_splits = [p for p in para_splits if p.strip()]
for p in para_splits:
if self.count_tokens(p).tokens_count > usable_budget:
raise IndivisibleTaskError(
f"Неделимый фрагмент ({self.count_tokens(p).tokens_count} токенов) превышает лимит ({usable_budget} токенов)."
)
code_units.append(p)
return self._pack_chunks(code_units, usable_budget)
def _pack_chunks(self, units: List[str], max_tokens: int) -> List[str]:
"""Greedily pack atomic units into contiguous chunks up to max_tokens."""
chunks: List[str] = []
current_chunk: List[str] = []
current_tokens = 0
for unit in units:
unit_tokens = self.count_tokens(unit).tokens_count
if current_chunk and (current_tokens + unit_tokens > max_tokens):
chunks.append("".join(current_chunk))
current_chunk = [unit]
current_tokens = unit_tokens
else:
current_chunk.append(unit)
current_tokens += unit_tokens
if current_chunk:
chunks.append("".join(current_chunk))
return chunks
# -------------------------------------------------------------
# P0-7: Outcome & Reasoning-Exhaustion Detection
# -------------------------------------------------------------
def detect_outcome(
self,
response_data: Optional[Dict[str, Any]],
error: Optional[Exception] = None,
elapsed_sec: float = 0.0,
timeout_threshold_sec: float = 180.0,
) -> Tuple[SupervisorOutcome, str]:
"""Classify generation outcome distinguishing timeout, error, success, and A39 reasoning exhaustion."""
if error is not None:
err_msg = str(error).lower()
if "timeout" in err_msg or "timed out" in err_msg or elapsed_sec >= timeout_threshold_sec:
return SupervisorOutcome.TIMEOUT, f"Превышен таймаут исполнения ({elapsed_sec:.1f}s >= {timeout_threshold_sec:.1f}s)"
return SupervisorOutcome.ERROR, f"Ошибка вызова: {error}"
if not response_data:
return SupervisorOutcome.ERROR, "Пустой ответ от сервера"
# Check choices / content
choices = response_data.get("choices", [])
if not choices:
return SupervisorOutcome.ERROR, "Отсутствуют варианты ответа (choices empty)"
msg = choices[0].get("message", {})
content = msg.get("content", "")
reasoning_content = msg.get("reasoning_content", "")
timings = response_data.get("timings", {})
predicted_n = timings.get("predicted_n", 0)
# A39 Case: Model spent all tokens thinking/looping with 0 actual output content
if (not content or content.strip() == "") and (predicted_n > 50 or bool(reasoning_content)):
return SupervisorOutcome.REASONING_EXHAUSTED, (
f"Кейс A39: потрачено {predicted_n} токенов на рассуждения, но 0 символов полезного ответа. "
"Требуется отключение thinking через request_options (enable_thinking: false)."
)
if not content or content.strip() == "":
return SupervisorOutcome.ERROR, "Модель вернула пустой контент"
return SupervisorOutcome.SUCCESS, f"Успешно сгенерировано ({len(content)} символов, {predicted_n} токенов за {elapsed_sec:.2f}s)"
# -------------------------------------------------------------
# P0-8: AI-Memory Tracking by GGUF Build Metadata
# -------------------------------------------------------------
def _load_all_memories(self) -> Dict[str, Dict[str, Any]]:
if not self.memory_path.exists():
return {}
try:
with open(self.memory_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.warning("Error reading local model memory from %s: %s", self.memory_path, e)
return {}
def get_model_memory(self, gguf_name: str) -> Optional[ModelMemoryRecord]:
"""Fetch historical performance and safe working volume for model."""
clean_name = self._normalize_gguf_name(gguf_name)
data = self._load_all_memories()
rec_data = data.get(clean_name)
if not rec_data:
return None
return ModelMemoryRecord(**rec_data)
def record_working_volume(
self,
gguf_name: str,
prompt_tokens: int,
output_tokens: int,
outcome: SupervisorOutcome,
speed_tps: float,
task_id: str = "general",
) -> ModelMemoryRecord:
"""Record successful or failed dispatch to canonical AI-Memory."""
clean_name = self._normalize_gguf_name(gguf_name)
data = self._load_all_memories()
now_iso = datetime.now(timezone.utc).isoformat()
current = data.get(clean_name)
if current:
rec = ModelMemoryRecord(**current)
else:
rec = ModelMemoryRecord(
gguf_name=clean_name,
safe_chunk_tokens=prompt_tokens if outcome == SupervisorOutcome.SUCCESS else 4096,
max_tested_tokens=prompt_tokens,
successful_dispatches=0,
failed_dispatches=0,
last_working_context=prompt_tokens if outcome == SupervisorOutcome.SUCCESS else 0,
avg_generation_tps=speed_tps,
last_updated=now_iso,
)
if outcome == SupervisorOutcome.SUCCESS:
rec.successful_dispatches += 1
rec.last_working_context = prompt_tokens
rec.max_tested_tokens = max(rec.max_tested_tokens, prompt_tokens)
# Smooth exponential safe chunk adjustment
if prompt_tokens > rec.safe_chunk_tokens:
rec.safe_chunk_tokens = prompt_tokens
if speed_tps > 0:
rec.avg_generation_tps = round((rec.avg_generation_tps * 0.7) + (speed_tps * 0.3), 2)
else:
rec.failed_dispatches += 1
# If failed at this volume, reduce safe chunk tokens by 20%
if prompt_tokens >= rec.safe_chunk_tokens and rec.safe_chunk_tokens > 2048:
rec.safe_chunk_tokens = max(2048, int(prompt_tokens * 0.8))
rec.last_updated = now_iso
rec.history.append({
"timestamp": now_iso,
"task_id": task_id,
"prompt_tokens": prompt_tokens,
"output_tokens": output_tokens,
"outcome": outcome.value,
"speed_tps": round(speed_tps, 2),
})
if len(rec.history) > 50:
rec.history = rec.history[-50:]
data[clean_name] = asdict(rec)
self._save_all_memories(data)
return rec
def _save_all_memories(self, data: Dict[str, Dict[str, Any]]):
try:
self.memory_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.memory_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error("Failed to save local model memory to %s: %s", self.memory_path, e)
def _normalize_gguf_name(self, name: str) -> str:
"""Extract clean model canonical identity from path or filename."""
clean = Path(name).stem
clean = clean.replace(".gguf", "").replace("-Q4_K_M", "").replace("-Instruct", "").strip()
return clean or "local-model"
# -------------------------------------------------------------
# P0-1, P0-2, P0-3, P0-4, P0-5: Context Compression Integration
# -------------------------------------------------------------
def compress_context_if_needed(
self,
messages: List[Dict[str, Any]],
target_context_limit: int,
compressor_profile: Optional[Any] = None,
threshold_percent: float = 75.0,
keep_recent_messages: int = 3,
timeout_sec: float = 60.0,
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
"""Compress old context via ContextCompressor if measured tokens exceed threshold."""
full_text = "\n\n".join(str(m.get("content", "")) for m in messages if isinstance(m, dict))
current_token_count = self.count_tokens(full_text).tokens_count
new_messages, outcome = self.compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=target_context_limit,
current_token_count=current_token_count,
compressor_profile=compressor_profile,
threshold_percent=threshold_percent,
keep_recent_messages=keep_recent_messages,
timeout_sec=timeout_sec,
)
# Сжиматель считает итог посимвольно (длина / 3.5): доступа к серверу у
# него нет. Здесь он есть, и степень сжатия — величина, которую владелец
# читает как измеренную. Пересчитываем настоящим токенизатором, а если
# он недоступен, честно помечаем оценкой.
if outcome.status == "SUCCESS":
new_text = MESSAGE_SEPARATOR.join(
str(m.get("content", "")) for m in new_messages if isinstance(m, dict)
)
counted = self.count_tokens(new_text)
outcome.tokens_after = counted.tokens_count
outcome.tokens_after_is_estimate = counted.is_estimated
outcome.saved_tokens = max(0, outcome.tokens_before - counted.tokens_count)
outcome.compression_ratio = round(
counted.tokens_count / max(1, outcome.tokens_before), 2
)
return new_messages, outcome
def get_compression_status(self, compressor_profile: Optional[Any] = None) -> Dict[str, Any]:
"""Return real-time diagnostic status of context compressor."""
if not compressor_profile and not os.environ.get("LOCAL_COMPRESSOR_BASE_URL"):
return {
"configured": False,
"status": "unconfigured",
"display_status": "Н/Д: модель для сжатия не выбрана",
"profile_id": None,
"endpoint": None,
"model": None,
"history_count": len(self.compressor._history_snapshots),
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
}
base_url, model_name, _ = self.compressor.resolve_compressor_endpoint(compressor_profile)
# Check health of compressor endpoint
is_healthy = False
n_ctx = 32768
props_url = f"{base_url}/props"
if props_url.endswith("/v1/props"):
props_url = props_url.replace("/v1/props", "/props")
try:
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-CompressorCheck/1.0"})
with urllib.request.urlopen(req, timeout=2.0) as resp:
data = json.loads(resp.read().decode("utf-8"))
gen_settings = data.get("default_generation_settings", {})
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 32768)
is_healthy = True
except Exception:
is_healthy = False
recent = self.compressor.get_compression_history(limit=5)
return {
"configured": True,
"status": "ready" if is_healthy else "offline",
"display_status": (
f"🟢 Готов: {model_name} ({base_url}) | Контекст: {n_ctx}"
if is_healthy
else f"⚠️ Недоступен: {base_url} (сжатие пропускается, задачи не прерываются)"
),
"profile_id": getattr(compressor_profile, "profile_id", None) or "local-compressor",
"endpoint": base_url,
"model": model_name,
"n_ctx": n_ctx,
"is_healthy": is_healthy,
"history_count": len(self.compressor._history_snapshots),
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
"recent_compressions": recent,
}