feat(a31): preflight dependency agent, workflow run state recovery, local concurrency and context guard, PII email masking, and cost-controller token honesty

This commit is contained in:
Hermes Team 2026-08-26 09:27:36 +07:00
parent 8b8aebf928
commit 59d57a41ef
14 changed files with 1365 additions and 14 deletions

View file

@ -102,6 +102,7 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` | | `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` | | `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
| `stop_workflow` | Запросить остановку текущего запуска | — | | `stop_workflow` | Запросить остановку текущего запуска | — |
| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — |
Состояние графа и LIVE-журнал приходят в поле `workflow` ответа Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку; `GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
@ -113,10 +114,11 @@ account_details add_account agent_settings apply_update
assign_role auto_assign_all check_updates delete_credentials assign_role auto_assign_all check_updates delete_credentials
edit_route get_update_status oauth open_routing edit_route get_update_status oauth open_routing
refresh_account refresh_all refresh_data refresh_models refresh_account refresh_all refresh_data refresh_models
reorder_chain save_chain save_settings set_main reorder_chain run_preflight save_chain save_settings
set_model set_orchestrator test set_main set_model set_orchestrator test
``` ```
Ответ: Ответ:
```json ```json

View file

@ -95,6 +95,11 @@ def get_workflow_state_path() -> Path:
return get_config_dir() / "workflow_state.json" return get_config_dir() / "workflow_state.json"
def get_workflow_run_state_path() -> Path:
"""Return the persisted active workflow run state file."""
return get_config_dir() / "workflow_run_state.json"
def get_agent_files_dir() -> Path: def get_agent_files_dir() -> Path:
"""Return the user-editable directory containing real Agent Files.""" """Return the user-editable directory containing real Agent Files."""
directory = get_hermes_home() / "agents" directory = get_hermes_home() / "agents"

View file

@ -630,5 +630,12 @@ class ActionExecutor:
status = mgr.get_status_dict() status = mgr.get_status_dict()
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status} return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
elif action == 'run_preflight':
from antigravity_provider.router.preflight_service import PreflightCheckService
service = PreflightCheckService.get()
report = service.run_all_checks()
msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений"
return {'ok': report.success, 'message': msg, 'data': report.to_dict()}
else: else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True} return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -46,6 +46,79 @@ class LocalLLMAdapter(BaseProviderAdapter):
return val return val
return None return None
_context_window_cache: Dict[str, int] = {}
def get_context_window(
self,
profile: RouterProfileConfig,
model: Optional[str] = None,
query_remote: bool = False,
) -> Optional[int]:
"""Fetch actual context_window / max_context_length from profile config or /models endpoint.
Never invents or hardcodes defaults. Returns None if unknown.
"""
# 1. Profile auth_config / custom settings
for key in ("context_window", "context_length", "max_context_length", "max_tokens_limit", "n_ctx"):
if key in profile.auth_config and profile.auth_config[key]:
try:
return int(profile.auth_config[key])
except (ValueError, TypeError):
pass
# 2. In-memory cache from previous model discovery
cache_key = f"{profile.profile_id}:{model or 'default'}"
if cache_key in self._context_window_cache:
return self._context_window_cache[cache_key]
if f"{profile.profile_id}:all" in self._context_window_cache:
return self._context_window_cache[f"{profile.profile_id}:all"]
if not query_remote:
return None
# 3. Query /models endpoint
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers = {"Accept": "application/json", "User-Agent": "hermes-router/1.0"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(f"{base_url}/models", headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
for m in items:
if isinstance(m, dict):
m_id = str(m.get("id") or m.get("name") or "")
for ck in ("context_window", "context_length", "max_model_len", "max_context_length", "n_ctx"):
if ck in m and m[ck]:
try:
ctx_val = int(m[ck])
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
return ctx_val
except (ValueError, TypeError):
pass
meta = m.get("meta") or {}
if isinstance(meta, dict):
for ck in ("n_ctx", "context_length", "max_context_length"):
if ck in meta and meta[ck]:
try:
ctx_val = int(meta[ck])
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
return ctx_val
except (ValueError, TypeError):
pass
except Exception as exc:
logger.debug("Failed to query context window from server for %s: %s", profile.profile_id, exc)
return None
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile) base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile) api_key = self._resolve_api_key(profile)
@ -54,9 +127,43 @@ class LocalLLMAdapter(BaseProviderAdapter):
if not model or model == "default": if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "default" model = profile.preferred_models[0] if profile.preferred_models else "default"
messages = list(request.get("messages", []))
# Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow
context_window = self.get_context_window(profile, model, query_remote=False)
if context_window is not None and context_window > 0 and len(messages) > 1:
max_tok = int(request.get("max_tokens", 0) or 0)
token_budget = context_window - max_tok - 64
if token_budget > 100:
def _est_tok(msgs: list) -> int:
total_chars = sum(len(str(m.get("content", ""))) for m in msgs if isinstance(m, dict))
return int(total_chars / 3.5) + len(msgs) * 4
if _est_tok(messages) > token_budget:
logger.warning(
"Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.",
profile.profile_id,
context_window,
)
system_msg = [messages[0]] if messages and messages[0].get("role") == "system" else []
last_msg = messages[-1]
middle = messages[1:-1] if system_msg else messages[:-1]
while middle and _est_tok(system_msg + middle + [last_msg]) > token_budget:
middle.pop(0)
if _est_tok(system_msg + middle + [last_msg]) > token_budget:
avail_chars = max(100, int(token_budget * 3.0))
last_copy = dict(last_msg)
last_copy["content"] = str(last_copy.get("content", ""))[-avail_chars:]
messages = system_msg + middle + [last_copy]
else:
messages = system_msg + middle + [last_msg]
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"model": model, "model": model,
"messages": request.get("messages", []), "messages": messages,
"temperature": request.get("temperature", 0.7), "temperature": request.get("temperature", 0.7),
} }
if "tools" in request and request["tools"]: if "tools" in request and request["tools"]:
@ -105,6 +212,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
return data return data
@staticmethod @staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None: def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Пустой ответ — это отказ, а не успех. """Пустой ответ — это отказ, а не успех.

View file

@ -0,0 +1,394 @@
"""Hermes Hub — Preflight Dependency & Readiness Check Service (Dependency Agent).
Performs comprehensive zero-quota preflight validation of local environment, CLI tools,
Python dependencies, local inference endpoints, role chain credentials, and disk permissions.
"""
from __future__ import annotations
import importlib.util
import logging
import os
import shutil
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional
from antigravity_provider import paths
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config
logger = logging.getLogger("hermes.router.preflight")
@dataclass
class PreflightItem:
check_id: str
name: str
status: str # "PASS" | "FAIL" | "WARN"
message: str
remediation: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class PreflightReport:
success: bool
passed_count: int
failed_count: int
warn_count: int
checks: List[PreflightItem] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"passed_count": self.passed_count,
"failed_count": self.failed_count,
"warn_count": self.warn_count,
"checks": [c.to_dict() for c in self.checks],
}
class PreflightCheckService:
"""Zero-quota dependency and readiness inspection service."""
_instance: Optional[PreflightCheckService] = None
@classmethod
def get(cls) -> PreflightCheckService:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def check_cli_dependencies(self) -> List[PreflightItem]:
"""Check for external CLI executables and critical Python packages."""
items: List[PreflightItem] = []
# 1. Antigravity CLI (agy)
agy_path = shutil.which("agy") or shutil.which("agy.exe")
if agy_path:
items.append(
PreflightItem(
check_id="cli_agy",
name="CLI Antigravity (agy)",
status="PASS",
message=f"Исполняемый файл agy найден: {agy_path}",
)
)
else:
items.append(
PreflightItem(
check_id="cli_agy",
name="CLI Antigravity (agy)",
status="WARN",
message="Утилита 'agy' не найдена в системном PATH.",
remediation="Установите agy CLI или добавьте каталог установки в системную переменную PATH.",
)
)
# 2. Python package: fastapi
fastapi_spec = importlib.util.find_spec("fastapi")
if fastapi_spec is not None:
items.append(
PreflightItem(
check_id="pkg_fastapi",
name="Библиотека FastAPI",
status="PASS",
message="Пакет fastapi успешно импортируется в окружении.",
)
)
else:
items.append(
PreflightItem(
check_id="pkg_fastapi",
name="Библиотека FastAPI",
status="FAIL",
message="Пакет 'fastapi' не установлен в текущем Python окружении.",
remediation="Выполните 'pip install fastapi' для работы веб-интерфейса и REST API.",
)
)
# 3. Python package: uvicorn
uvicorn_spec = importlib.util.find_spec("uvicorn")
if uvicorn_spec is not None:
items.append(
PreflightItem(
check_id="pkg_uvicorn",
name="Библиотека Uvicorn",
status="PASS",
message="Пакет uvicorn успешно импортируется в окружении.",
)
)
else:
items.append(
PreflightItem(
check_id="pkg_uvicorn",
name="Библиотека Uvicorn",
status="FAIL",
message="Пакет 'uvicorn' не установлен в текущем Python окружении.",
remediation="Выполните 'pip install uvicorn' для запуска веб-сервера.",
)
)
return items
def check_local_servers(self) -> List[PreflightItem]:
"""Poll {base_url}/models with 2.0s timeout for active local provider profiles."""
items: List[PreflightItem] = []
config = load_router_config()
local_profiles = [p for p in config.profiles.values() if p.provider == "local" and p.enabled]
if not local_profiles:
items.append(
PreflightItem(
check_id="local_servers_none",
name="Локальные серверы LLM",
status="PASS",
message="Активные локальные профили (llama.cpp/vLLM) не настроены.",
)
)
return items
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
adapter = LocalLLMAdapter()
for pcfg in local_profiles:
base_url = adapter._resolve_base_url(pcfg)
api_key = adapter._resolve_api_key(pcfg)
headers = {"Accept": "application/json", "User-Agent": "hermes-preflight/1.0"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
models_url = f"{base_url}/models"
try:
req = urllib.request.Request(models_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=2.0) as resp:
if resp.status in (200, 204):
items.append(
PreflightItem(
check_id=f"local_srv_{pcfg.profile_id}",
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
status="PASS",
message=f"Локальный сервер доступен (HTTP {resp.status}).",
)
)
else:
items.append(
PreflightItem(
check_id=f"local_srv_{pcfg.profile_id}",
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
status="FAIL",
message=f"Сервер вернул неожиданный статус HTTP {resp.status}",
remediation=f"Проверьте настройки и логи сервера {base_url}.",
)
)
except urllib.error.HTTPError as http_err:
items.append(
PreflightItem(
check_id=f"local_srv_{pcfg.profile_id}",
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
status="FAIL",
message=f"HTTP ошибка при обращении к {models_url}: {http_err.code} {http_err.reason}",
remediation=f"Убедитесь, что сервер на {base_url} поддерживает OpenAI-совместимый эндпоинт /v1/models.",
)
)
except Exception as exc:
items.append(
PreflightItem(
check_id=f"local_srv_{pcfg.profile_id}",
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
status="FAIL",
message=f"Не удалось подключиться к {base_url}: {exc}",
remediation=f"Запустите локальный сервер llama.cpp / vLLM / Ollama по адресу {base_url}.",
)
)
return items
def check_auth_credentials(self) -> List[PreflightItem]:
"""Verify credential presence for all profiles referenced in active role chains.
ZERO QUOTA BURN: Only inspects local auth files and keyring status. Never calls paid APIs.
"""
items: List[PreflightItem] = []
config = load_router_config()
# Collect all profile IDs in active role chains
referenced_pids: set[str] = set()
for role_policy in config.roles.values():
for pid in role_policy.preferred_chain:
referenced_pids.add(pid)
if not referenced_pids:
items.append(
PreflightItem(
check_id="auth_chains_empty",
name="Учетные данные цепочек ролей",
status="WARN",
message="В активных ролях не настроены цепочки профилей.",
remediation="Настройте цепочки профилей в разделе Маршрутизация.",
)
)
return items
for pid in sorted(referenced_pids):
pcfg = config.get_profile(pid)
if not pcfg:
items.append(
PreflightItem(
check_id=f"auth_{pid}",
name=f"Профиль {pid}",
status="FAIL",
message=f"Профиль '{pid}' указан в цепочке роли, но отсутствует в конфигурации.",
remediation=f"Удалите '{pid}' из цепочки роли или настройте профиль в router_profiles.yaml.",
)
)
continue
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
is_authenticated = status.get("authenticated", False)
is_expired = status.get("is_expired", False) or status.get("expired", False) or status.get("status") == "EXPIRED"
if is_authenticated and not is_expired:
items.append(
PreflightItem(
check_id=f"auth_{pid}",
name=f"Авторизация {pid} ({pcfg.provider})",
status="PASS",
message="Учетные данные действительны и сохранены локально.",
)
)
elif is_expired:
items.append(
PreflightItem(
check_id=f"auth_{pid}",
name=f"Авторизация {pid} ({pcfg.provider})",
status="FAIL",
message=f"Срок действия авторизации для профиля '{pid}' истек.",
remediation=f"Выполните повторный вход для профиля {pid} в разделе Аккаунты.",
)
)
else:
items.append(
PreflightItem(
check_id=f"auth_{pid}",
name=f"Авторизация {pid} ({pcfg.provider})",
status="FAIL",
message=f"Учетные данные для профиля '{pid}' ({pcfg.provider}) не найдены.",
remediation=f"Подключите профиль {pid} через кнопку 'Добавить аккаунт' или 'hermes router login'.",
)
)
return items
def check_system_environment(self) -> List[PreflightItem]:
"""Verify HERMES_HOME presence and read/write permissions for config and logs."""
items: List[PreflightItem] = []
# 1. HERMES_HOME directory
try:
home_dir = paths.get_hermes_home()
if home_dir.is_dir():
items.append(
PreflightItem(
check_id="env_hermes_home",
name="Каталог HERMES_HOME",
status="PASS",
message=f"Каталог существует: {home_dir}",
)
)
else:
items.append(
PreflightItem(
check_id="env_hermes_home",
name="Каталог HERMES_HOME",
status="FAIL",
message=f"Каталог {home_dir} не существует или не является директорией.",
remediation="Проверьте права доступа и создайте каталог HERMES_HOME.",
)
)
except Exception as exc:
items.append(
PreflightItem(
check_id="env_hermes_home",
name="Каталог HERMES_HOME",
status="FAIL",
message=f"Ошибка доступа к HERMES_HOME: {exc}",
remediation="Убедитесь, что переменная HERMES_HOME указывает на корректный доступный путь.",
)
)
# 2. Config Directory Write Test
try:
config_dir = paths.get_config_dir()
test_file = config_dir / f".preflight_probe_{os.getpid()}.tmp"
test_file.write_text("probe", encoding="utf-8")
test_file.unlink()
items.append(
PreflightItem(
check_id="env_config_writable",
name="Права на запись в каталог конфигурации",
status="PASS",
message=f"Права на запись в {config_dir} подтверждены.",
)
)
except Exception as exc:
items.append(
PreflightItem(
check_id="env_config_writable",
name="Права на запись в каталог конфигурации",
status="FAIL",
message=f"Нет прав на запись в {paths.get_config_dir()}: {exc}",
remediation="Предоставьте текущему пользователю права на запись в каталог конфигурации.",
)
)
# 3. Logs Directory Write Test
try:
logs_dir = paths.get_logs_dir()
test_file = logs_dir / f".preflight_probe_{os.getpid()}.tmp"
test_file.write_text("probe", encoding="utf-8")
test_file.unlink()
items.append(
PreflightItem(
check_id="env_logs_writable",
name="Права на запись в каталог логов",
status="PASS",
message=f"Права на запись в {logs_dir} подтверждены.",
)
)
except Exception as exc:
items.append(
PreflightItem(
check_id="env_logs_writable",
name="Права на запись в каталог логов",
status="FAIL",
message=f"Нет прав на запись в {paths.get_logs_dir()}: {exc}",
remediation="Предоставьте текущему пользователю права на запись в каталог логов.",
)
)
return items
def run_all_checks(self) -> PreflightReport:
"""Run all readiness checks and return aggregated PreflightReport."""
all_items: List[PreflightItem] = []
all_items.extend(self.check_cli_dependencies())
all_items.extend(self.check_system_environment())
all_items.extend(self.check_auth_credentials())
all_items.extend(self.check_local_servers())
passed = sum(1 for item in all_items if item.status == "PASS")
failed = sum(1 for item in all_items if item.status == "FAIL")
warn = sum(1 for item in all_items if item.status == "WARN")
return PreflightReport(
success=(failed == 0),
passed_count=passed,
failed_count=failed,
warn_count=warn,
checks=all_items,
)

View file

@ -178,6 +178,18 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
max_failover_attempts=3, max_failover_attempts=3,
tier="expert", tier="expert",
), ),
"dependency-agent": RoleDefinition(
role_id="dependency-agent",
display_name_ru="Проверяющий готовность",
short_name_ru="Готовность",
description_ru="До начала задачи убеждается, что на месте всё необходимое — исполняемые файлы и CLI, библиотеки, учётные данные, права доступа, доступность локальных серверов. Сообщает о нехватке до запуска.",
is_implemented=True,
capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
fallback_capabilities=["dependency-agent", "preflight"],
default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"],
max_failover_attempts=3,
tier="qa_doc",
),
} }
_CANONICAL_ROLE_ALIASES: Dict[str, str] = { _CANONICAL_ROLE_ALIASES: Dict[str, str] = {
@ -215,6 +227,13 @@ _CANONICAL_ROLE_ALIASES: Dict[str, str] = {
"специалист по интеграции": "integration-expert", "специалист по интеграции": "integration-expert",
"безопасность": "security-expert", "безопасность": "security-expert",
"специалист по безопасности": "security-expert", "специалист по безопасности": "security-expert",
"dependency-agent": "dependency-agent",
"dependency_agent": "dependency-agent",
"preflight": "dependency-agent",
"проверяющий готовность": "dependency-agent",
"агент зависимостей": "dependency-agent",
"готовность": "dependency-agent",
"dependency": "dependency-agent",
} }
class RoleRegistry: class RoleRegistry:
@ -272,6 +291,10 @@ class RoleRegistry:
clean = name_or_alias.strip().lower() clean = name_or_alias.strip().lower()
return _CANONICAL_ROLE_ALIASES.get(clean, clean) return _CANONICAL_ROLE_ALIASES.get(clean, clean)
@classmethod
def resolve_role_name(cls, name_or_alias: str) -> str:
return cls.resolve_canonical_role(name_or_alias)
@classmethod @classmethod
def get_canonical_role_map(cls) -> Dict[str, str]: def get_canonical_role_map(cls) -> Dict[str, str]:
return dict(_CANONICAL_ROLE_ALIASES) return dict(_CANONICAL_ROLE_ALIASES)

View file

@ -303,16 +303,20 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
profiles_raw = data.get("profiles", {}) profiles_raw = data.get("profiles", {})
profiles: dict[str, RouterProfileConfig] = {} profiles: dict[str, RouterProfileConfig] = {}
for pid, pdata in profiles_raw.items(): for pid, pdata in profiles_raw.items():
provider = pdata.get("provider", "antigravity")
max_concurrency = int(pdata.get("max_concurrency", 1))
if provider == "local":
max_concurrency = 1
profiles[pid] = RouterProfileConfig( profiles[pid] = RouterProfileConfig(
profile_id=pid, profile_id=pid,
provider=pdata.get("provider", "antigravity"), provider=provider,
account_id=pdata.get("account_id", pid), account_id=pdata.get("account_id", pid),
capabilities=list(pdata.get("capabilities", [])), capabilities=list(pdata.get("capabilities", [])),
preferred_models=list(pdata.get("preferred_models", [])), preferred_models=list(pdata.get("preferred_models", [])),
fallback_models=list(pdata.get("fallback_models", [])), fallback_models=list(pdata.get("fallback_models", [])),
auth_config=dict(pdata.get("auth_config", {})), auth_config=dict(pdata.get("auth_config", {})),
enabled=bool(pdata.get("enabled", True)), enabled=bool(pdata.get("enabled", True)),
max_concurrency=int(pdata.get("max_concurrency", 1)), max_concurrency=max_concurrency,
custom_base_url=pdata.get("custom_base_url"), custom_base_url=pdata.get("custom_base_url"),
) )

View file

@ -22,9 +22,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"monitoring_interval_seconds": 30, "monitoring_interval_seconds": 30,
"quota_threshold_percent": 10.0, "quota_threshold_percent": 10.0,
"quota_threshold_action": "notify", "quota_threshold_action": "notify",
"email_masking_mode": "none",
} }
_SETTINGS_CACHE: Dict[str, Any] | None = None _SETTINGS_CACHE: Dict[str, Any] | None = None
_SETTINGS_CACHE_MTIME: float = -1.0 _SETTINGS_CACHE_MTIME: float = -1.0
_SETTINGS_CACHE_PATH: str = "" _SETTINGS_CACHE_PATH: str = ""
@ -98,6 +100,11 @@ def get_hub_settings() -> Dict[str, Any]:
action = "notify" action = "notify"
merged["quota_threshold_action"] = action merged["quota_threshold_action"] = action
email_mode = str(merged.get("email_masking_mode", "none")).strip().lower()
if email_mode not in ("none", "partial", "full"):
email_mode = "none"
merged["email_masking_mode"] = email_mode
_SETTINGS_CACHE = dict(merged) _SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime _SETTINGS_CACHE_MTIME = current_mtime
_SETTINGS_CACHE_PATH = sfile_str _SETTINGS_CACHE_PATH = sfile_str

View file

@ -31,6 +31,15 @@ MAX_FILE_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_BACKUP_FILES = 3 MAX_BACKUP_FILES = 3
def format_token_count(measured: Optional[int], estimated: Optional[int]) -> Optional[str]:
"""Format token count distinguishing measured exact counts from estimations (~)."""
if measured is not None:
return str(measured)
elif estimated is not None:
return f"~{estimated}"
return None
@dataclass @dataclass
class TelemetryRecord: class TelemetryRecord:
"""Immutable record of an individual router invocation attempt.""" """Immutable record of an individual router invocation attempt."""
@ -45,6 +54,13 @@ class TelemetryRecord:
prompt_tokens: Optional[int] = None prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None total_tokens: Optional[int] = None
prompt_tokens_measured: Optional[int] = None
prompt_tokens_estimated: Optional[int] = None
completion_tokens_measured: Optional[int] = None
completion_tokens_estimated: Optional[int] = None
total_tokens_measured: Optional[int] = None
total_tokens_estimated: Optional[int] = None
is_estimated: bool = False
cost_usd: Optional[float] = None cost_usd: Optional[float] = None
failover_count: int = 0 failover_count: int = 0
error_category: Optional[str] = None error_category: Optional[str] = None
@ -69,6 +85,14 @@ class TelemetryAggregates:
total_prompt_tokens: Optional[int] = None # Sum of reported prompt tokens or None if no token data total_prompt_tokens: Optional[int] = None # Sum of reported prompt tokens or None if no token data
total_completion_tokens: Optional[int] = None total_completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None total_tokens: Optional[int] = None
total_prompt_tokens_measured: Optional[int] = None
total_prompt_tokens_estimated: Optional[int] = None
total_completion_tokens_measured: Optional[int] = None
total_completion_tokens_estimated: Optional[int] = None
total_tokens_measured: Optional[int] = None
total_tokens_estimated: Optional[int] = None
tokens_display: Optional[str] = None
has_estimated_tokens: bool = False
total_cost_usd: Optional[float] = None # Sum of calculated costs or None if no pricing available total_cost_usd: Optional[float] = None # Sum of calculated costs or None if no pricing available
failovers_count: int = 0 failovers_count: int = 0
failover_reasons: Dict[str, int] = field(default_factory=dict) failover_reasons: Dict[str, int] = field(default_factory=dict)
@ -79,6 +103,7 @@ class TelemetryAggregates:
return asdict(self) return asdict(self)
class TelemetryService: class TelemetryService:
"""Thread-safe persistent telemetry manager with bounded storage and honest aggregation.""" """Thread-safe persistent telemetry manager with bounded storage and honest aggregation."""
@ -176,16 +201,42 @@ class TelemetryService:
total_tokens: Optional[int] = None, total_tokens: Optional[int] = None,
failover_count: int = 0, failover_count: int = 0,
error_category: Optional[str] = None, error_category: Optional[str] = None,
prompt_tokens_measured: Optional[int] = None,
prompt_tokens_estimated: Optional[int] = None,
completion_tokens_measured: Optional[int] = None,
completion_tokens_estimated: Optional[int] = None,
total_tokens_measured: Optional[int] = None,
total_tokens_estimated: Optional[int] = None,
is_estimated: bool = False,
) -> TelemetryRecord: ) -> TelemetryRecord:
"""Record an invocation attempt into memory and rotated log.""" """Record an invocation attempt into memory and rotated log."""
now = time.time() now = time.time()
iso = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") iso = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Derive total tokens if prompt/completion available if is_estimated:
if total_tokens is None and prompt_tokens is not None and completion_tokens is not None: p_est = prompt_tokens_estimated if prompt_tokens_estimated is not None else prompt_tokens
total_tokens = prompt_tokens + completion_tokens c_est = completion_tokens_estimated if completion_tokens_estimated is not None else completion_tokens
t_est = total_tokens_estimated if total_tokens_estimated is not None else total_tokens
if t_est is None and p_est is not None and c_est is not None:
t_est = p_est + c_est
p_meas, c_meas, t_meas = None, None, None
p_tok, c_tok, t_tok = p_est, c_est, t_est
else:
p_meas = prompt_tokens_measured if prompt_tokens_measured is not None else prompt_tokens
c_meas = completion_tokens_measured if completion_tokens_measured is not None else completion_tokens
t_meas = total_tokens_measured if total_tokens_measured is not None else total_tokens
if t_meas is None and p_meas is not None and c_meas is not None:
t_meas = p_meas + c_meas
p_est = prompt_tokens_estimated
c_est = completion_tokens_estimated
t_est = total_tokens_estimated
if t_est is None and p_est is not None and c_est is not None:
t_est = p_est + c_est
p_tok = p_meas if p_meas is not None else p_est
c_tok = c_meas if c_meas is not None else c_est
t_tok = t_meas if t_meas is not None else t_est
cost_usd = self.compute_cost(model, prompt_tokens, completion_tokens) cost_usd = self.compute_cost(model, p_tok, c_tok)
record = TelemetryRecord( record = TelemetryRecord(
timestamp=now, timestamp=now,
@ -196,9 +247,16 @@ class TelemetryService:
model=model, model=model,
outcome=outcome, outcome=outcome,
latency_seconds=round(max(0.0, float(latency_seconds)), 4), latency_seconds=round(max(0.0, float(latency_seconds)), 4),
prompt_tokens=prompt_tokens, prompt_tokens=p_tok,
completion_tokens=completion_tokens, completion_tokens=c_tok,
total_tokens=total_tokens, total_tokens=t_tok,
prompt_tokens_measured=p_meas,
prompt_tokens_estimated=p_est,
completion_tokens_measured=c_meas,
completion_tokens_estimated=c_est,
total_tokens_measured=t_meas,
total_tokens_estimated=t_est,
is_estimated=is_estimated or (p_est is not None and p_meas is None),
cost_usd=cost_usd, cost_usd=cost_usd,
failover_count=failover_count, failover_count=failover_count,
error_category=error_category, error_category=error_category,
@ -268,6 +326,13 @@ class TelemetryService:
prompt_tokens=d.get("prompt_tokens"), prompt_tokens=d.get("prompt_tokens"),
completion_tokens=d.get("completion_tokens"), completion_tokens=d.get("completion_tokens"),
total_tokens=d.get("total_tokens"), total_tokens=d.get("total_tokens"),
prompt_tokens_measured=d.get("prompt_tokens_measured"),
prompt_tokens_estimated=d.get("prompt_tokens_estimated"),
completion_tokens_measured=d.get("completion_tokens_measured"),
completion_tokens_estimated=d.get("completion_tokens_estimated"),
total_tokens_measured=d.get("total_tokens_measured"),
total_tokens_estimated=d.get("total_tokens_estimated"),
is_estimated=bool(d.get("is_estimated", False)),
cost_usd=d.get("cost_usd"), cost_usd=d.get("cost_usd"),
failover_count=int(d.get("failover_count", 0)), failover_count=int(d.get("failover_count", 0)),
error_category=d.get("error_category"), error_category=d.get("error_category"),
@ -318,6 +383,14 @@ class TelemetryService:
total_prompt_tokens=None, total_prompt_tokens=None,
total_completion_tokens=None, total_completion_tokens=None,
total_tokens=None, total_tokens=None,
total_prompt_tokens_measured=None,
total_prompt_tokens_estimated=None,
total_completion_tokens_measured=None,
total_completion_tokens_estimated=None,
total_tokens_measured=None,
total_tokens_estimated=None,
tokens_display=None,
has_estimated_tokens=False,
total_cost_usd=None, total_cost_usd=None,
failovers_count=0, failovers_count=0,
failover_reasons={}, failover_reasons={},
@ -333,6 +406,15 @@ class TelemetryService:
prompt_tokens_sum = 0 prompt_tokens_sum = 0
completion_tokens_sum = 0 completion_tokens_sum = 0
has_any_token_data = False has_any_token_data = False
prompt_meas_sum = 0
prompt_est_sum = 0
has_meas_prompt = False
has_est_prompt = False
comp_meas_sum = 0
comp_est_sum = 0
has_meas_comp = False
has_est_comp = False
has_estimated_tokens = False
costs_sum = 0.0 costs_sum = 0.0
has_any_cost_data = False has_any_cost_data = False
failovers_count = 0 failovers_count = 0
@ -358,6 +440,41 @@ class TelemetryService:
completion_tokens_sum += r.completion_tokens completion_tokens_sum += r.completion_tokens
has_any_token_data = True has_any_token_data = True
if r.prompt_tokens_measured is not None:
prompt_meas_sum += r.prompt_tokens_measured
has_meas_prompt = True
elif r.prompt_tokens is not None and not r.is_estimated:
prompt_meas_sum += r.prompt_tokens
has_meas_prompt = True
if r.prompt_tokens_estimated is not None:
prompt_est_sum += r.prompt_tokens_estimated
has_est_prompt = True
has_estimated_tokens = True
elif r.prompt_tokens is not None and r.is_estimated:
prompt_est_sum += r.prompt_tokens
has_est_prompt = True
has_estimated_tokens = True
if r.completion_tokens_measured is not None:
comp_meas_sum += r.completion_tokens_measured
has_meas_comp = True
elif r.completion_tokens is not None and not r.is_estimated:
comp_meas_sum += r.completion_tokens
has_meas_comp = True
if r.completion_tokens_estimated is not None:
comp_est_sum += r.completion_tokens_estimated
has_est_comp = True
has_estimated_tokens = True
elif r.completion_tokens is not None and r.is_estimated:
comp_est_sum += r.completion_tokens
has_est_comp = True
has_estimated_tokens = True
if r.is_estimated:
has_estimated_tokens = True
if r.cost_usd is not None: if r.cost_usd is not None:
costs_sum += r.cost_usd costs_sum += r.cost_usd
has_any_cost_data = True has_any_cost_data = True
@ -369,6 +486,9 @@ class TelemetryService:
error_rate = round(failed_calls / total_calls, 4) if total_calls > 0 else 0.0 error_rate = round(failed_calls / total_calls, 4) if total_calls > 0 else 0.0
total_tokens_sum = (prompt_tokens_sum + completion_tokens_sum) if has_any_token_data else None total_tokens_sum = (prompt_tokens_sum + completion_tokens_sum) if has_any_token_data else None
tot_meas_sum = (prompt_meas_sum + comp_meas_sum) if (has_meas_prompt or has_meas_comp) else None
tot_est_sum = (prompt_est_sum + comp_est_sum) if (has_est_prompt or has_est_comp) else None
tokens_display = format_token_count(tot_meas_sum, tot_est_sum)
return TelemetryAggregates( return TelemetryAggregates(
window_seconds=window_seconds, window_seconds=window_seconds,
@ -383,6 +503,14 @@ class TelemetryService:
total_prompt_tokens=prompt_tokens_sum if has_any_token_data else None, total_prompt_tokens=prompt_tokens_sum if has_any_token_data else None,
total_completion_tokens=completion_tokens_sum if has_any_token_data else None, total_completion_tokens=completion_tokens_sum if has_any_token_data else None,
total_tokens=total_tokens_sum, total_tokens=total_tokens_sum,
total_prompt_tokens_measured=prompt_meas_sum if has_meas_prompt else None,
total_prompt_tokens_estimated=prompt_est_sum if has_est_prompt else None,
total_completion_tokens_measured=comp_meas_sum if has_meas_comp else None,
total_completion_tokens_estimated=comp_est_sum if has_est_comp else None,
total_tokens_measured=tot_meas_sum,
total_tokens_estimated=tot_est_sum,
tokens_display=tokens_display,
has_estimated_tokens=has_estimated_tokens,
total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None, total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None,
failovers_count=failovers_count, failovers_count=failovers_count,
failover_reasons=dict(failover_reasons), failover_reasons=dict(failover_reasons),
@ -390,6 +518,7 @@ class TelemetryService:
has_data=True, has_data=True,
) )
def get_breakdown( def get_breakdown(
self, self,
window_seconds: Optional[int] = 86400, window_seconds: Optional[int] = 86400,

View file

@ -125,8 +125,17 @@ def health_check():
} }
} }
def sanitize_snapshot(snap_dict: Any) -> Any: def sanitize_snapshot(snap_dict: Any, email_masking_mode: Optional[str] = None) -> Any:
import re import re
if email_masking_mode is None:
try:
from antigravity_provider.router.settings_service import get_hub_settings
email_masking_mode = get_hub_settings().get("email_masking_mode", "none")
except Exception:
email_masking_mode = "none"
mode = str(email_masking_mode or "none").strip().lower()
secret_patterns = [ secret_patterns = [
re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE), re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE),
re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'), re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'),
@ -134,6 +143,23 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE), re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE),
] ]
email_pattern = re.compile(r'\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b')
def _mask_email_match(match: re.Match) -> str:
local_part = match.group(1)
domain_part = match.group(2)
if mode == "full":
return "***@***.***"
elif mode == "partial":
if len(local_part) > 2:
masked = f"{local_part[0]}***{local_part[-1]}"
elif local_part:
masked = f"{local_part[0]}***"
else:
masked = "***"
return f"{masked}@{domain_part}"
return match.group(0)
def _mask_str(val: str) -> str: def _mask_str(val: str) -> str:
res = val res = val
for pat in secret_patterns: for pat in secret_patterns:
@ -141,6 +167,8 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
res = pat.sub(r'\g<1>***', res) res = pat.sub(r'\g<1>***', res)
elif pat.groups == 1: elif pat.groups == 1:
res = pat.sub(r'***', res) res = pat.sub(r'***', res)
if mode in ("partial", "full"):
res = email_pattern.sub(_mask_email_match, res)
return res return res
def _sanitize(node): def _sanitize(node):
@ -156,6 +184,7 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
return node return node
return _sanitize(snap_dict) return _sanitize(snap_dict)
@app.get("/api/snapshot") @app.get("/api/snapshot")
def get_snapshot(authorized: bool = Depends(get_auth_token)): def get_snapshot(authorized: bool = Depends(get_auth_token)):
snapshot = HubStateStore.get().get_snapshot() snapshot = HubStateStore.get().get_snapshot()

View file

@ -182,8 +182,15 @@ function initEventListeners() {
if (btnApplyUpdate) { if (btnApplyUpdate) {
btnApplyUpdate.addEventListener('click', () => applyUpdate()); btnApplyUpdate.addEventListener('click', () => applyUpdate());
} }
// Preflight check listener
const btnPreflight = document.getElementById('btn-run-preflight');
if (btnPreflight) {
btnPreflight.addEventListener('click', () => runPreflightChecks());
}
} }
// ── SNAPSHOT INGESTION & MONOTONIC SEQ ── // ── SNAPSHOT INGESTION & MONOTONIC SEQ ──
async function fetchSnapshot() { async function fetchSnapshot() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -1313,6 +1320,7 @@ function renderSettingsView() {
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
@ -1321,6 +1329,9 @@ function renderSettingsView() {
if (quotaActionSel && s.quota_threshold_action) { if (quotaActionSel && s.quota_threshold_action) {
quotaActionSel.value = s.quota_threshold_action; quotaActionSel.value = s.quota_threshold_action;
} }
if (emailMaskingSel && s.email_masking_mode) {
emailMaskingSel.value = s.email_masking_mode;
}
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
monitorIntervalInput.value = s.monitoring_interval_seconds; monitorIntervalInput.value = s.monitoring_interval_seconds;
} }
@ -1329,11 +1340,13 @@ function renderSettingsView() {
async function saveHubServerSettings() { async function saveHubServerSettings() {
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const quotaActionSel = document.getElementById('setting-quota-threshold-action');
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const newSettings = { const newSettings = {
quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0,
quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify',
email_masking_mode: emailMaskingSel ? emailMaskingSel.value : 'none',
monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30, monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30,
}; };
@ -1347,6 +1360,73 @@ async function saveHubServerSettings() {
} }
} }
// ── PREFLIGHT READINESS CHECKS ──
async function runPreflightChecks() {
const container = document.getElementById('preflight-results-container');
const btn = document.getElementById('btn-run-preflight');
if (btn) btn.disabled = true;
if (container) {
container.innerHTML = '<div class="loading-state" style="padding:12px; font-size:13px; color:var(--text-secondary);">⏳ Запуск zero-quota проверки зависимостей и окружения...</div>';
}
try {
const res = await executeAction('run_preflight', {});
if (!res) throw new Error('Сервер не вернул ответ');
const report = res.data || {};
renderPreflightReport(report, container);
showToast(res.message || 'Проверка готовности завершена', res.ok ? 'success' : 'warning');
} catch (err) {
if (container) {
container.innerHTML = `<div class="modal-feedback error">❌ Ошибка выполнения проверки: ${escapeHtml(err.message || String(err))}</div>`;
}
showToast('Ошибка при запуске проверки готовности', 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function renderPreflightReport(report, container) {
if (!container) return;
const checks = report.checks || [];
const passed = report.passed_count || 0;
const failed = report.failed_count || 0;
const warn = report.warn_count || 0;
const statusBadge = `<span class="badge ${failed === 0 ? 'healthy' : 'error'}">${failed === 0 ? 'Все проверки пройдены' : `Обнаружено ошибок: ${failed}`}</span>`;
let html = `
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; padding:10px 14px; background:var(--surface-muted); border-radius:var(--radius-sm);">
<div style="font-weight:600; font-size:13px;">Результат: ${statusBadge}</div>
<div style="font-size:12px; color:var(--text-muted);">
Пройдено: <strong style="color:var(--status-healthy);">${passed}</strong>
Ошибок: <strong style="color:var(--status-error);">${failed}</strong>
Предупреждений: <strong style="color:var(--status-warning);">${warn}</strong>
</div>
</div>
<div class="preflight-list" style="display:flex; flex-direction:column; gap:8px;">
`;
checks.forEach((item) => {
const badgeClass = item.status === 'PASS' ? 'healthy' : (item.status === 'WARN' ? 'warning' : 'error');
const icon = item.status === 'PASS' ? '✓' : (item.status === 'WARN' ? '⚠' : '✕');
html += `
<div class="preflight-item" style="padding:10px 14px; background:var(--surface-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<div style="font-weight:600; font-size:13px; display:flex; align-items:center; gap:8px;">
<span class="badge ${badgeClass}" style="padding:2px 8px; font-size:11px;">${icon} ${escapeHtml(item.status)}</span>
<span>${escapeHtml(item.name || item.check_id)}</span>
</div>
</div>
<div style="font-size:12px; color:var(--text-secondary); margin-left:4px;">${escapeHtml(item.message || '')}</div>
${item.remediation ? `<div style="font-size:12px; color:var(--status-warning); margin-top:4px; margin-left:4px; font-style:italic;">💡 Рекомендация: ${escapeHtml(item.remediation)}</div>` : ''}
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function initSettings() { function initSettings() {
const btnSave = document.getElementById('btn-save-client-settings'); const btnSave = document.getElementById('btn-save-client-settings');
const tokenInput = document.getElementById('setting-client-token-input'); const tokenInput = document.getElementById('setting-client-token-input');

View file

@ -277,9 +277,23 @@
<!-- Readiness state banner rendered by app.js --> <!-- Readiness state banner rendered by app.js -->
</div> </div>
<div class="section-card" id="health-preflight-card">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<div class="section-card-title">Проверка готовности (Preflight / Dependency Agent)</div>
<div class="section-card-subtitle">Zero-quota аудит окружения, зависимостей, прав доступа и локальных серверов</div>
</div>
<button class="btn btn-secondary btn-sm" id="btn-run-preflight">Запустить проверку готовности</button>
</div>
<div id="preflight-results-container" style="padding-top:12px;">
<!-- Rendered by app.js -->
</div>
</div>
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
<div class="section-card-title">Ресурсы хост-системы</div> <div class="section-card-title">Ресурсы хост-системы</div>
<div class="section-card-subtitle">Мониторинг CPU, памяти, диска и сетевых параметров</div> <div class="section-card-subtitle">Мониторинг CPU, памяти, диска и сетевых параметров</div>
</div> </div>
<div class="host-resources-grid" id="health-host-resources"> <div class="host-resources-grid" id="health-host-resources">
@ -402,6 +416,19 @@
</select> </select>
</div> </div>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Маскирование почты (PII)</div>
<div class="setting-desc">Уровень маскирования адресов электронной почты в снапшотах и логах</div>
</div>
<div class="setting-control">
<select id="setting-email-masking-mode" class="select-filter">
<option value="none" selected>Без маскирования (по умолчанию)</option>
<option value="partial">Частичное (v***@gmail.com)</option>
<option value="full">Полное (***@***.***)</option>
</select>
</div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<div class="setting-label">Тема оформления</div> <div class="setting-label">Тема оформления</div>

View file

@ -35,6 +35,47 @@ def _slug(value: str) -> str:
return result or f"agent-{uuid.uuid4().hex[:8]}" return result or f"agent-{uuid.uuid4().hex[:8]}"
def sanitize_run_data(node: Any) -> Any:
"""Recursively strip or mask any credentials or secret keys from workflow run state."""
secret_key_substrings = [
"api_key", "token", "password", "secret", "jwt", "bearer",
"access_token", "refresh_token", "client_secret", "authorization",
]
if isinstance(node, dict):
sanitized: dict[str, Any] = {}
for k, v in node.items():
k_lower = str(k).lower()
if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"):
sanitized[k] = "***"
else:
sanitized[k] = sanitize_run_data(v)
return sanitized
elif isinstance(node, list):
return [sanitize_run_data(x) for x in node]
elif isinstance(node, str):
val = node
val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE)
val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val)
val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val)
val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE)
return val
return node
def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
"""Return the last saved workflow run state from workflow_run_state.json, if any."""
p = run_state_path or paths.get_workflow_run_state_path()
if not p.is_file():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, dict):
return sanitize_run_data(data)
except Exception:
pass
return None
def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]: def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]:
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal.""" """Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
root = paths.get_agent_files_dir().resolve() root = paths.get_agent_files_dir().resolve()
@ -103,8 +144,9 @@ class WorkflowService:
_instance: Optional["WorkflowService"] = None _instance: Optional["WorkflowService"] = None
_instance_lock = threading.Lock() _instance_lock = threading.Lock()
def __init__(self, state_path: Optional[Path] = None) -> None: def __init__(self, state_path: Optional[Path] = None, run_state_path: Optional[Path] = None) -> None:
self.state_path = state_path or paths.get_workflow_state_path() self.state_path = state_path or paths.get_workflow_state_path()
self.run_state_path = run_state_path or paths.get_workflow_run_state_path()
self._lock = threading.RLock() self._lock = threading.RLock()
self._stop = threading.Event() self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
@ -112,6 +154,7 @@ class WorkflowService:
self.workflow = WorkflowDefinition() self.workflow = WorkflowDefinition()
self.events: list[WorkflowEvent] = [] self.events: list[WorkflowEvent] = []
self.run: dict[str, Any] = self._idle_run() self.run: dict[str, Any] = self._idle_run()
self._completed_steps: list[dict[str, Any]] = []
self._load() self._load()
self._migrate_router_roles() self._migrate_router_roles()
@ -140,6 +183,25 @@ class WorkflowService:
} }
def _load(self) -> None: def _load(self) -> None:
# 1. Load run state from workflow_run_state.json
if self.run_state_path.is_file():
try:
run_state = json.loads(self.run_state_path.read_text(encoding="utf-8"))
if isinstance(run_state, dict):
self._completed_steps = list(run_state.get("completed_steps", []))
if run_state.get("status") in {"RUNNING", "running", "STOPPING", "stopping"}:
run_state["status"] = "INTERRUPTED"
run_state["interruption_reason"] = "Прогон был прерван перезапуском сервера или сбоем процесса"
run_state["updated_at"] = _utc_timestamp()
sanitized = sanitize_run_data(run_state)
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
temp = self.run_state_path.with_suffix(".tmp")
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(self.run_state_path)
except Exception:
self._completed_steps = []
# 2. Load workflow definition and events from workflow_state.json
if not self.state_path.is_file(): if not self.state_path.is_file():
return return
try: try:
@ -164,6 +226,42 @@ class WorkflowService:
self.events = [] self.events = []
self.run = self._idle_run() self.run = self._idle_run()
def _save_run_state(
self,
status: str,
step_index: int = 0,
current_agent: Optional[str] = None,
iteration: int = 1,
completed_step: Optional[dict[str, Any]] = None,
interruption_reason: Optional[str] = None,
) -> None:
if completed_step:
self._completed_steps.append(sanitize_run_data(completed_step))
state_payload = {
"run_id": self.run.get("id"),
"status": status.upper(),
"started_at": self.run.get("started_at"),
"updated_at": _utc_timestamp(),
"current_step_index": step_index,
"current_agent_id": current_agent,
"iteration_count": iteration,
"completed_steps": list(self._completed_steps),
"interruption_reason": interruption_reason,
}
sanitized = sanitize_run_data(state_payload)
try:
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
temp = self.run_state_path.with_suffix(".tmp")
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(self.run_state_path)
except Exception:
pass
def get_last_run_state(self) -> Optional[dict[str, Any]]:
"""Return the last saved run state from workflow_run_state.json."""
return get_last_run_state(self.run_state_path)
def _save(self) -> None: def _save(self) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True) self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = { payload = {
@ -425,6 +523,7 @@ class WorkflowService:
if not start_id or start_id not in self.agents: if not start_id or start_id not in self.agents:
raise ValueError("В workflow нет стартового агента") raise ValueError("В workflow нет стартового агента")
self._stop.clear() self._stop.clear()
self._completed_steps = []
self.run = self._idle_run() self.run = self._idle_run()
self.run.update({ self.run.update({
"id": uuid.uuid4().hex, "id": uuid.uuid4().hex,
@ -436,6 +535,7 @@ class WorkflowService:
}) })
self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1) self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1)
self._save() self._save()
self._save_run_state("RUNNING", step_index=0, current_agent=start_id, iteration=1)
self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True) self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True)
self._thread.start() self._thread.start()
return dict(self.run) return dict(self.run)
@ -448,6 +548,7 @@ class WorkflowService:
self._stop.set() self._stop.set()
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning") self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
self._save() self._save()
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем")
return dict(self.run) return dict(self.run)
def _execute(self) -> None: def _execute(self) -> None:
@ -466,10 +567,12 @@ class WorkflowService:
visited[current] = visited.get(current, 0) + 1 visited[current] = visited.get(current, 0) + 1
iteration = max(visited.values()) iteration = max(visited.values())
self.run.update({"current_agent_id": current, "iteration": iteration}) self.run.update({"current_agent_id": current, "iteration": iteration})
step_idx = len(self._completed_steps)
if iteration > self.workflow.max_iterations: if iteration > self.workflow.max_iterations:
message = f"Достигнут предел итераций: {self.workflow.max_iterations}" message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
self.run.update({"status": "failed", "error": message}) self.run.update({"status": "failed", "error": message})
self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration) self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration)
self._save_run_state("FAILED", step_index=step_idx, current_agent=current, iteration=iteration, interruption_reason=message)
break break
file_data = self.read_agent_file(current) file_data = self.read_agent_file(current)
if not file_data["exists"]: if not file_data["exists"]:
@ -479,6 +582,7 @@ class WorkflowService:
) )
self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration) self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration)
self._save() self._save()
self._save_run_state("RUNNING", step_index=step_idx, current_agent=current, iteration=iteration)
request = { request = {
"model": self._execution_config(agent).get("model"), "model": self._execution_config(agent).get("model"),
"messages": [ "messages": [
@ -515,6 +619,19 @@ class WorkflowService:
duration_seconds=duration, duration_seconds=duration,
error=text if status == "ERROR" else None, error=text if status == "ERROR" else None,
) )
step_summary = {
"step_index": step_idx,
"agent_id": current,
"agent_name": agent.name,
"iteration": iteration,
"status": status,
"duration_seconds": duration,
"provider": metadata.get("provider"),
"account": metadata.get("profile_id"),
"model": metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
"error": text if status in {"ERROR", "REVIEW_FAILED"} else None,
"timestamp": _utc_timestamp(),
}
edge = next( edge = next(
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}), (item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
None, None,
@ -548,6 +665,14 @@ class WorkflowService:
) )
except Exception: except Exception:
pass pass
self._save_run_state(
"FAILED" if self.run["status"] == "failed" else "COMPLETED",
step_index=step_idx + 1,
current_agent=current,
iteration=iteration,
completed_step=step_summary,
interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None,
)
break break
self._event( self._event(
"WORKFLOW_TRANSITION", "WORKFLOW_TRANSITION",
@ -562,10 +687,18 @@ class WorkflowService:
}, ensure_ascii=False) }, ensure_ascii=False)
current = edge.target current = edge.target
self._save() self._save()
self._save_run_state(
"RUNNING",
step_index=step_idx + 1,
current_agent=current,
iteration=iteration,
completed_step=step_summary,
)
with self._lock: with self._lock:
if self._stop.is_set(): if self._stop.is_set():
self.run.update({"status": "stopped", "error": "Остановлено пользователем"}) self.run.update({"status": "stopped", "error": "Остановлено пользователем"})
self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning") self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning")
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=current, iteration=iteration, interruption_reason="Остановлено пользователем")
except Exception as exc: except Exception as exc:
with self._lock: with self._lock:
self.run.update({"status": "failed", "error": str(exc)}) self.run.update({"status": "failed", "error": str(exc)})
@ -576,6 +709,7 @@ class WorkflowService:
EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error") EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error")
except Exception: except Exception:
pass pass
self._save_run_state("FAILED", step_index=len(self._completed_steps), current_agent=current, iteration=visited.get(current, 1), interruption_reason=str(exc))
finally: finally:
with self._lock: with self._lock:
self.run["finished_at"] = _utc_timestamp() self.run["finished_at"] = _utc_timestamp()
@ -583,6 +717,7 @@ class WorkflowService:
self.run["current_agent_id"] = current or self.run.get("current_agent_id") self.run["current_agent_id"] = current or self.run.get("current_agent_id")
self._save() self._save()
@staticmethod @staticmethod
def _response_text(response: Any) -> str: def _response_text(response: Any) -> str:
if not isinstance(response, dict): if not isinstance(response, dict):
@ -644,3 +779,7 @@ def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]
except Exception: except Exception:
pass pass
return {"ok": True, "message": "Выполнено", "data": result} return {"ok": True, "message": "Выполнено", "data": result}
# Alias for execution service compatibility
WorkflowExecutionService = WorkflowService

View file

@ -0,0 +1,397 @@
"""Tests for Task A31: Preflight Dependency Agent, Workflow Run State, Local Concurrency & Context Window, PII Masking, and Cost Controller Honesty.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider import paths
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
from antigravity_provider.router.preflight_service import PreflightCheckService, PreflightReport
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
get_default_router_config,
load_router_config,
)
from antigravity_provider.router.settings_service import (
DEFAULT_SETTINGS,
get_hub_settings,
invalidate_settings_cache,
save_hub_settings,
)
from antigravity_provider.router.telemetry_service import (
TelemetryAggregates,
TelemetryRecord,
TelemetryService,
format_token_count,
)
from antigravity_provider.router.web.server import sanitize_snapshot
from antigravity_provider.router.workflow_service import (
AgentDefinition,
WorkflowDefinition,
WorkflowExecutionService,
WorkflowService,
get_last_run_state,
sanitize_run_data,
)
# ============================================================================
# P0-1: Preflight Dependency Agent
# ============================================================================
def test_dependency_agent_role_registered():
"""Verify 13th role 'dependency-agent' and its canonical aliases in RoleRegistry."""
assert len(CANONICAL_ROLES) == 13
assert "dependency-agent" in CANONICAL_ROLES
role_def = CANONICAL_ROLES["dependency-agent"]
assert role_def.role_id == "dependency-agent"
assert role_def.display_name_ru == "Проверяющий готовность"
assert role_def.short_name_ru == "Готовность"
assert role_def.is_implemented is True
assert "preflight" in role_def.capabilities
# Test alias resolution
aliases = [
"dependency-agent",
"dependency_agent",
"preflight",
"проверяющий готовность",
"агент зависимостей",
"готовность",
"dependency",
]
for alias in aliases:
canonical = RoleRegistry.resolve_role_name(alias)
assert canonical == "dependency-agent", f"Alias '{alias}' resolved to '{canonical}'"
def test_preflight_service_cli_and_environment():
"""Verify CLI tools and environment checks."""
service = PreflightCheckService.get()
cli_items = service.check_cli_dependencies()
assert len(cli_items) >= 3
ids = {item.check_id for item in cli_items}
assert "cli_agy" in ids
assert "pkg_fastapi" in ids
assert "pkg_uvicorn" in ids
env_items = service.check_system_environment()
assert len(env_items) >= 3
env_ids = {item.check_id for item in env_items}
assert "env_hermes_home" in env_ids
assert "env_config_writable" in env_ids
assert "env_logs_writable" in env_ids
def test_preflight_service_run_all_and_action():
"""Verify run_all_checks and action execution."""
service = PreflightCheckService.get()
report = service.run_all_checks()
assert isinstance(report, PreflightReport)
assert isinstance(report.passed_count, int)
assert isinstance(report.failed_count, int)
assert isinstance(report.warn_count, int)
assert len(report.checks) > 0
report_dict = report.to_dict()
assert "success" in report_dict
assert "checks" in report_dict
assert isinstance(report_dict["checks"], list)
# Test via ActionExecutor
action_res = ActionExecutor.execute("run_preflight", {})
assert "ok" in action_res
assert "message" in action_res
assert "data" in action_res
assert "checks" in action_res["data"]
# ============================================================================
# P0-2: Workflow Run State Manager
# ============================================================================
def test_workflow_run_state_sanitization():
"""Verify recursive secret stripping in workflow run state."""
raw_state = {
"run_id": "test-run-123",
"status": "RUNNING",
"api_key": "sk-1234567890abcdef",
"token": "gho_secrettoken123456",
"nested": {
"password": "supersecretpass",
"auth_status": "ok",
"message": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0",
"extra_url": "https://example.com/callback?access_token=secret12345&foo=bar",
},
"step_list": [
{"account": "acc-1", "client_secret": "my-client-secret-123"},
{"safe_field": "public_data"},
],
}
sanitized = sanitize_run_data(raw_state)
assert sanitized["api_key"] == "***"
assert sanitized["token"] == "***"
assert sanitized["nested"]["password"] == "***"
assert sanitized["nested"]["auth_status"] == "ok"
assert "Bearer ***" in sanitized["nested"]["message"]
assert "access_token=***" in sanitized["nested"]["extra_url"]
assert sanitized["step_list"][0]["client_secret"] == "***"
assert sanitized["step_list"][1]["safe_field"] == "public_data"
def test_workflow_run_state_interrupted_on_startup(tmp_path: Path):
"""Verify that a RUNNING state in workflow_run_state.json transitions to INTERRUPTED on reload."""
state_file = tmp_path / "workflow_state.json"
run_state_file = tmp_path / "workflow_run_state.json"
# Pre-populate run state with RUNNING status
initial_run_state = {
"run_id": "run-crash-test",
"status": "RUNNING",
"started_at": "2026-08-26T00:00:00Z",
"updated_at": "2026-08-26T00:00:00Z",
"current_step_index": 2,
"current_agent_id": "developer-1",
"iteration_count": 1,
"completed_steps": [
{"step_index": 0, "agent_id": "manager", "status": "SUCCESS"},
{"step_index": 1, "agent_id": "developer-1", "status": "WORKING"},
],
"interruption_reason": None,
}
run_state_file.write_text(json.dumps(initial_run_state), encoding="utf-8")
# Initialize WorkflowService
service = WorkflowService(state_path=state_file, run_state_path=run_state_file)
# Check that state transitioned to INTERRUPTED
last_state = service.get_last_run_state()
assert last_state is not None
assert last_state["status"] == "INTERRUPTED"
assert last_state["interruption_reason"] == "Прогон был прерван перезапуском сервера или сбоем процесса"
assert len(last_state["completed_steps"]) == 2
# Verify top-level function
assert get_last_run_state(run_state_file)["status"] == "INTERRUPTED"
def test_workflow_execution_service_alias():
"""Verify WorkflowExecutionService is an alias of WorkflowService."""
assert WorkflowExecutionService is WorkflowService
# ============================================================================
# P0-3: Local Concurrency & Context Window
# ============================================================================
def test_local_profile_max_concurrency_is_one():
"""Verify all local provider profiles have max_concurrency = 1."""
config = get_default_router_config()
for pid, pcfg in config.profiles.items():
if pcfg.provider == "local":
assert pcfg.max_concurrency == 1, f"Local profile {pid} has max_concurrency={pcfg.max_concurrency}"
# Verify loaded config also enforces max_concurrency = 1 for local profiles
loaded = load_router_config()
for pid, pcfg in loaded.profiles.items():
if pcfg.provider == "local":
assert pcfg.max_concurrency == 1
def test_local_adapter_get_context_window():
"""Verify LocalLLMAdapter retrieves context window accurately without hallucinating defaults."""
adapter = LocalLLMAdapter()
# Profile with explicit context_window in auth_config
prof_with_cfg = RouterProfileConfig(
profile_id="local-test-1",
provider="local",
account_id="acc-1",
auth_config={"context_window": 8192},
)
assert adapter.get_context_window(prof_with_cfg) == 8192
# Profile without context length and with non-responding server
prof_empty = RouterProfileConfig(
profile_id="local-test-2",
provider="local",
account_id="acc-2",
custom_base_url="http://127.0.0.1:9999/v1",
)
# Must return None instead of inventing fake numbers
assert adapter.get_context_window(prof_empty) is None
def test_local_adapter_context_truncation_guard():
"""Verify context truncation guard protects against VRAM overflow when context_window is known."""
adapter = LocalLLMAdapter()
prof = RouterProfileConfig(
profile_id="local-small-ctx",
provider="local",
account_id="acc-1",
auth_config={"context_window": 500},
custom_base_url="http://127.0.0.1:12345/v1",
)
# Huge prompt exceeding 500 tokens
long_middle_content = "important historical dialogue step " * 100
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Initial prompt 1"},
{"role": "assistant", "content": long_middle_content},
{"role": "user", "content": "Initial prompt 2"},
{"role": "assistant", "content": long_middle_content},
{"role": "user", "content": "Latest user task to execute."},
]
mock_resp = {
"choices": [{"message": {"role": "assistant", "content": "Truncated prompt executed successfully."}}],
"usage": {"prompt_tokens": 200, "completion_tokens": 10},
}
with patch("urllib.request.urlopen") as mock_urlopen:
mock_cm = MagicMock()
mock_cm.read.return_value = json.dumps(mock_resp).encode("utf-8")
mock_urlopen.return_value.__enter__.return_value = mock_cm
res = adapter.invoke(prof, {"messages": messages, "max_tokens": 100})
assert res["choices"][0]["message"]["content"] == "Truncated prompt executed successfully."
# Verify sent payload messages were truncated
args, kwargs = mock_urlopen.call_args
sent_req = args[0]
sent_body = json.loads(sent_req.data.decode("utf-8"))
sent_messages = sent_body["messages"]
assert sent_messages[0]["role"] == "system"
assert sent_messages[-1]["content"] == "Latest user task to execute."
# Total count of messages should be pruned
assert len(sent_messages) < len(messages)
# ============================================================================
# P0-4: PII Email Masking
# ============================================================================
def test_settings_email_masking_mode():
"""Verify email_masking_mode in default settings and persistence."""
assert DEFAULT_SETTINGS["email_masking_mode"] == "none"
settings = get_hub_settings()
assert settings.get("email_masking_mode") in ("none", "partial", "full")
def test_sanitize_snapshot_email_masking_modes():
"""Verify email masking behavior across 'none', 'partial', and 'full' modes."""
snapshot_data = {
"user_email": "vasya.pupkin@example.com",
"account_id": "google-user-1",
"api_key": "sk-secret123456789",
"nested": {
"developer": "developer.one@domain.org",
"reviewer": "r@test.com",
},
}
# 1. Mode: none (emails unchanged, secrets masked)
san_none = sanitize_snapshot(snapshot_data, email_masking_mode="none")
assert san_none["user_email"] == "vasya.pupkin@example.com"
assert san_none["nested"]["developer"] == "developer.one@domain.org"
assert san_none["nested"]["reviewer"] == "r@test.com"
assert "api_key" not in san_none
# 2. Mode: partial (preserves first and last char of local part + domain for differentiation)
san_partial = sanitize_snapshot(snapshot_data, email_masking_mode="partial")
assert san_partial["user_email"] == "v***n@example.com"
assert san_partial["nested"]["developer"] == "d***e@domain.org"
assert san_partial["nested"]["reviewer"] == "r***@test.com"
assert "api_key" not in san_partial
# 3. Mode: full (***@***.***)
san_full = sanitize_snapshot(snapshot_data, email_masking_mode="full")
assert san_full["user_email"] == "***@***.***"
assert san_full["nested"]["developer"] == "***@***.***"
assert san_full["nested"]["reviewer"] == "***@***.***"
assert "api_key" not in san_full
# ============================================================================
# P0-5: Cost Controller Token Honesty
# ============================================================================
def test_telemetry_measured_vs_estimated_tokens(tmp_path: Path):
"""Verify telemetry distinguishes measured exact tokens from estimated tokens with ~."""
log_file = tmp_path / "telemetry_test.jsonl"
service = TelemetryService(log_path=log_file)
# 1. Record measured call
rec1 = service.record_call(
role="developer-1",
profile_id="ag-w1",
provider="antigravity",
model="claude-3-7-sonnet",
outcome="success",
latency_seconds=1.25,
prompt_tokens_measured=500,
completion_tokens_measured=150,
is_estimated=False,
)
assert rec1.prompt_tokens_measured == 500
assert rec1.prompt_tokens_estimated is None
assert rec1.is_estimated is False
assert rec1.total_tokens == 650
# 2. Record estimated call
rec2 = service.record_call(
role="tester",
profile_id="local-1",
provider="local",
model="Qwen3.8-27B-Q4_K_M.gguf",
outcome="success",
latency_seconds=0.85,
prompt_tokens_estimated=300,
completion_tokens_estimated=50,
is_estimated=True,
)
assert rec2.prompt_tokens_measured is None
assert rec2.prompt_tokens_estimated == 300
assert rec2.is_estimated is True
assert rec2.total_tokens == 350
# 3. Aggregates for measured only
agg_measured = service.get_aggregates(profile_id="ag-w1")
assert agg_measured.total_tokens_measured == 650
assert agg_measured.tokens_display == "650"
assert agg_measured.has_estimated_tokens is False
# 4. Aggregates for estimated only
agg_est = service.get_aggregates(profile_id="local-1")
assert agg_est.total_tokens_estimated == 350
assert agg_est.tokens_display == "~350"
assert agg_est.has_estimated_tokens is True
def test_format_token_count():
"""Verify format_token_count formatting helper."""
assert format_token_count(1250, None) == "1250"
assert format_token_count(None, 1250) == "~1250"
assert format_token_count(1000, 250) == "1000"
assert format_token_count(None, None) is None