fix(ci): A33 stop hanging tests and stabilize CI #1

Merged
ochenstarik-ui merged 11 commits from codex/a33-test-hang-ci-stabilization into main 2026-08-30 18:47:51 +00:00
63 changed files with 4644 additions and 1801 deletions

View file

@ -10,6 +10,7 @@ jobs:
test: test:
name: Clean Windows Runner Test name: Clean Windows Runner Test
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -24,7 +25,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e .[dev] pip install -e ".[dev,web]"
- name: Code Quality (ruff) - name: Code Quality (ruff)
run: | run: |
@ -41,6 +42,7 @@ jobs:
headless: headless:
name: Headless Run (no GUI dependencies) name: Headless Run (no GUI dependencies)
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -54,7 +56,7 @@ jobs:
- name: Install dependencies without GUI extras - name: Install dependencies without GUI extras
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e .[dev] pip install -e ".[dev,web]"
pip uninstall -y customtkinter pip uninstall -y customtkinter
# A test module importing customtkinter at module scope aborts collection # A test module importing customtkinter at module scope aborts collection

View file

@ -22,7 +22,7 @@ jobs:
- name: Install dependencies & dev tools - name: Install dependencies & dev tools
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e .[dev] pip install -e ".[dev,web]"
- name: Run Release Gate Check - name: Run Release Gate Check
run: | run: |

View file

@ -0,0 +1,43 @@
# Задание Antigravity: полный release gate после A30
## Цель
Провести независимую проверку ветки `codex/workflow-canvas` после реализации A30. Проверять фактическое состояние репозитория и запускаемого приложения, а не описание работы.
## Обязательный порядок
1. Получить актуальные `origin/main` и `origin/codex/workflow-canvas`.
2. Проверить `git status`, базовый и финальный SHA ветки.
3. Запустить приложение из чистого checkout ветки A30.
4. Выполнить полный `pytest`/release gate и сохранить полный вывод.
5. Выполнить `ruff check .`.
6. Проверить веб-контракт: `/`, `/api/snapshot`, `/api/events`, `/api/action`.
7. Проверить A30 вручную в браузере: LIVE, EDIT, создание агента, назначение Provider → Account → Model, Agent File, редактор ребра, цикл и предел итераций.
8. Отдельно проверить честность данных: отсутствие mock/demo чисел из макета, `Н/Д` с причиной, loading не смешан с отсутствием данных.
9. Проверить persistence после перезапуска и реальные provider errors.
10. Проверить, что desktop `router/ui/**` не изменён A30.
## Правила отчёта
- Не писать `PASS`, если полный release gate не запускался.
- Не считать targeted tests заменой полного regression.
- Для каждого failure привести команду, stdout/stderr, файл и минимальный способ воспроизведения.
- Если блокер связан с окружением, повторить проверку в чистом окружении или явно указать, что именно не проверено.
## Артефакты
Передать:
- `START_HEAD`, `FINAL_HEAD`, `origin/main`;
- чистый `git status` или полный список загрязнений;
- `X passed / Y skipped / Z failed`;
- точный результат `scripts/release_gate.py`;
- список найденных дефектов с приоритетом P0P3;
- скриншоты LIVE, EDIT, Inspector, Agent File и редактора ребра;
- отдельный список пропущенных проверок.
## Ограничения
- Ничего не исправлять молча в чужой ветке: найденные дефекты оформить отдельным патчем/коммитом или вернуть владельцу.
- Не удалять пользовательские изменения в установщике, бинарниках и заданиях inbox.
- Не объявлять release-ready при известных блокерах.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -90,15 +90,35 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
Имена действий берутся **ровно** из общего слоя `action_handler.py`: Имена действий берутся **ровно** из общего слоя `action_handler.py`:
Действия Agent Manager и Workflow (A30):
| Action | Назначение | Обязательные данные |
|---|---|---|
| `create_agent` | Создать логического агента, роль маршрутизатора и Agent File | `name`, `role`; опционально `profile_id`, `model`, настройки исполнения |
| `update_agent` | Изменить свойства и назначение Provider → Account → Model | `agent_id`; назначение задаётся `provider`, `profile_id`, `model` |
| `delete_agent` | Удалить агента; при ссылках сначала возвращает `confirmation_required` | `agent_id`; после подтверждения `force: true` |
| `read_agent_file` | Прочитать реальный Markdown Agent File | `agent_id` |
| `save_agent_file` | Атомарно сохранить Agent File для последующих запусков | `agent_id`, `content` |
| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
| `stop_workflow` | Запросить остановку текущего запуска | — |
| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — |
Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
`unavailable_reason` означает отсутствие данных с явной причиной. Показатели
workflow нельзя подменять фикстурой при недоступности API.
``` ```
account_details add_account agent_settings apply_update 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

@ -46,6 +46,7 @@ dependencies = [
dev = [ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.23.0", "pytest-asyncio>=0.23.0",
"pytest-timeout>=2.3.0",
"anyio>=4.0.0", "anyio>=4.0.0",
"ruff>=0.3.0", "ruff>=0.3.0",
] ]
@ -65,6 +66,8 @@ testpaths = ["tests"]
pythonpath = ["src"] pythonpath = ["src"]
python_files = ["test_*.py"] python_files = ["test_*.py"]
addopts = "-m 'not live and not network and not installer'" addopts = "-m 'not live and not network and not installer'"
timeout = 30
timeout_method = "thread"
markers = [ markers = [
"unit: Unit tests that run isolated in-memory", "unit: Unit tests that run isolated in-memory",
"integration: Component integration tests with isolated filesystem", "integration: Component integration tests with isolated filesystem",

View file

@ -221,10 +221,15 @@ def check_production_update_feed() -> tuple[bool, str]:
with urllib.request.urlopen(req, timeout=6) as resp: with urllib.request.urlopen(req, timeout=6) as resp:
if resp.status == 200: if resp.status == 200:
data = json.loads(resp.read().decode("utf-8-sig")) data = json.loads(resp.read().decode("utf-8-sig"))
p_ver = data.get("version") p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
p_url = data.get("package_url") p_url = data.get("package_url")
if not p_ver or not p_url: if not p_url and data.get("assets"):
return False, "Public update manifest is missing version or package_url" p_url = data["assets"][0].get("browser_download_url")
if not p_url:
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
if not p_ver:
return False, "Public update manifest is missing version or tag_name"
# Verify package URL reachability # Verify package URL reachability
pkg_live = False pkg_live = False

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Hard wall-clock wrapper for the hermetic pytest suite.
A hanging suite is terminated. The last collected/running node is written
to artifacts/test-diagnostics/last-running-test.txt when possible.
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_LIMIT = 480 # 8 minutes: enough for ~500 hermetic tests, not 15 hours
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="Wall-clock seconds")
parser.add_argument("pytest_args", nargs=argparse.REMAINDER)
args = parser.parse_args()
pytest_args = list(args.pytest_args)
if pytest_args and pytest_args[0] == "--":
pytest_args = pytest_args[1:]
out_dir = ROOT / "artifacts" / "test-diagnostics"
out_dir.mkdir(parents=True, exist_ok=True)
last_path = out_dir / "last-running-test.txt"
cmd = [
args.python,
"-X",
"faulthandler",
"-m",
"pytest",
"-vv",
"--tb=short",
*pytest_args,
]
env = os.environ.copy()
env["PYTHONFAULTHANDLER"] = "1"
start = time.monotonic()
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
start_new_session=True,
)
try:
return_code = proc.wait(timeout=args.limit)
last_path.write_text(
f"completed rc={return_code} duration={time.monotonic() - start:.1f}s\n",
encoding="utf-8",
)
return return_code
except subprocess.TimeoutExpired:
last_path.write_text(
f"TIMEOUT after {args.limit}s pid={proc.pid}\ncmd={' '.join(cmd)}\n",
encoding="utf-8",
)
try:
os.killpg(proc.pid, signal.SIGABRT)
time.sleep(0.5)
except (ProcessLookupError, PermissionError, OSError):
pass
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
print(
f"HERMETIC SUITE WALL CLOCK EXCEEDED ({args.limit}s). Killed pid={proc.pid}. "
f"See {last_path}",
file=sys.stderr,
)
return 124
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Per-file and per-node pytest hang diagnostic runner.
Each file (or node) is a separate subprocess with a hard timeout.
One hang never blocks the rest of the suite.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUT = ROOT / "artifacts" / "test-diagnostics"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def list_test_files() -> list[Path]:
tests_dir = ROOT / "tests"
return sorted(p for p in tests_dir.glob("test_*.py") if p.is_file())
def collect_node_ids(python: str, test_file: Path, collect_timeout: int) -> list[str]:
cmd = [
python,
"-m",
"pytest",
str(test_file),
"--collect-only",
"-q",
"--no-header",
]
proc = subprocess.run(
cmd,
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=collect_timeout,
env=os.environ.copy(),
)
nodes: list[str] = []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(str(test_file).replace("\\", "/")) or line.startswith("tests/"):
if "::" in line and not line.startswith("="):
nodes.append(line.split()[0])
elif "::" in line and not line.startswith("=") and "error" not in line.lower():
if line.startswith("test_") or "/test_" in line or line.startswith("tests"):
nodes.append(line.split()[0])
# Fallback: pytest -q collect prints node ids as first token.
if not nodes:
for line in (proc.stdout or "").splitlines():
stripped = line.strip()
if "::" in stripped and not stripped.startswith("="):
nodes.append(stripped.split()[0])
return nodes
def _kill_tree(proc: subprocess.Popen) -> None:
if proc.poll() is not None:
return
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def run_guarded(
python: str,
target: str,
timeout_s: int,
log_dir: Path,
extra_args: list[str] | None = None,
) -> dict:
safe_name = target.replace("/", "_").replace("::", "__").replace("[", "_").replace("]", "_")
stdout_path = log_dir / f"{safe_name}.stdout.txt"
stderr_path = log_dir / f"{safe_name}.stderr.txt"
dump_path = log_dir / f"{safe_name}.faulthandler.txt"
cmd = [
python,
"-X",
"faulthandler",
"-m",
"pytest",
target,
"-vv",
"--tb=short",
"-p",
"no:cacheprovider",
]
if extra_args:
cmd.extend(extra_args)
env = os.environ.copy()
env["PYTHONFAULTHANDLER"] = "1"
start = time.monotonic()
start_iso = utc_now()
status = "UNKNOWN"
return_code: int | None = None
timed_out = False
dump = ""
stdout_text = ""
stderr_text = ""
try:
with open(stdout_path, "w", encoding="utf-8") as out_f, open(
stderr_path, "w", encoding="utf-8"
) as err_f:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
stdout=out_f,
stderr=err_f,
text=True,
start_new_session=True,
env=env,
)
try:
return_code = proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
timed_out = True
try:
os.killpg(proc.pid, signal.SIGABRT)
time.sleep(0.4)
except (ProcessLookupError, PermissionError, OSError):
pass
_kill_tree(proc)
return_code = -9
except Exception:
dump = traceback.format_exc()
status = "ERROR"
return_code = -1
duration = round(time.monotonic() - start, 3)
try:
stdout_text = stdout_path.read_text(encoding="utf-8", errors="replace")
except OSError:
stdout_text = ""
try:
stderr_text = stderr_path.read_text(encoding="utf-8", errors="replace")
except OSError:
stderr_text = ""
if timed_out:
status = "TIMEOUT"
dump_parts = [dump, "=== STDERR TAIL ===\n" + stderr_text[-8000:], "=== STDOUT TAIL ===\n" + stdout_text[-8000:]]
dump = "\n".join(p for p in dump_parts if p)
dump_path.write_text(dump, encoding="utf-8")
elif return_code == 0:
status = "PASS"
else:
status = "FAIL"
dump_path.write_text(
(stderr_text[-8000:] + "\n" + stdout_text[-8000:]),
encoding="utf-8",
)
return {
"target": target,
"start_time": start_iso,
"duration": duration,
"return_code": return_code,
"status": status,
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"dump_path": str(dump_path) if dump_path.exists() else None,
"timed_out": timed_out,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--file-timeout", type=int, default=90)
parser.add_argument("--node-timeout", type=int, default=30)
parser.add_argument("--collect-timeout", type=int, default=30)
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
parser.add_argument("--mode", choices=["files", "nodes"], default="files")
parser.add_argument("--file", action="append", default=[])
args = parser.parse_args()
out_dir = args.out
logs = out_dir / ("file-logs" if args.mode == "files" else "node-logs")
logs.mkdir(parents=True, exist_ok=True)
if args.file:
files = [Path(f) if Path(f).is_absolute() else ROOT / f for f in args.file]
else:
files = list_test_files()
results: list[dict] = []
if args.mode == "files":
for path in files:
rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
print(f"[RUN FILE] {rel}", flush=True)
rec = run_guarded(args.python, rel, args.file_timeout, logs)
rec["file"] = rel
results.append(rec)
print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
payload = {
"generated_at": utc_now(),
"mode": "files",
"file_timeout": args.file_timeout,
"results": results,
"summary": {
"total": len(results),
"pass": sum(1 for r in results if r["status"] == "PASS"),
"fail": sum(1 for r in results if r["status"] == "FAIL"),
"timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
},
}
out_path = out_dir / "file-results.json"
else:
for path in files:
rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
print(f"[COLLECT] {rel}", flush=True)
try:
nodes = collect_node_ids(args.python, Path(rel), args.collect_timeout)
except subprocess.TimeoutExpired:
results.append(
{
"file": rel,
"target": rel,
"status": "COLLECT_TIMEOUT",
"start_time": utc_now(),
"duration": args.collect_timeout,
"return_code": -9,
"timed_out": True,
}
)
continue
if not nodes:
print(f" no nodes collected for {rel}", flush=True)
continue
for node in nodes:
print(f"[RUN NODE] {node}", flush=True)
rec = run_guarded(args.python, node, args.node_timeout, logs)
rec["file"] = rel
rec["node_id"] = node
results.append(rec)
print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
payload = {
"generated_at": utc_now(),
"mode": "nodes",
"node_timeout": args.node_timeout,
"results": results,
"summary": {
"total": len(results),
"pass": sum(1 for r in results if r["status"] == "PASS"),
"fail": sum(1 for r in results if r["status"] == "FAIL"),
"timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
},
}
out_path = out_dir / "node-results.json"
out_dir.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(json.dumps(payload["summary"], indent=2), flush=True)
print(f"wrote {out_path}", flush=True)
return 0 if payload["summary"]["timeout"] == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -85,6 +85,28 @@ def get_router_active_profile_path() -> Path:
return get_config_dir() / "router_active_profile.json" return get_config_dir() / "router_active_profile.json"
def get_workflow_state_path() -> Path:
"""Return the persisted agent/workflow state sidecar.
Logical roles and their execution routes remain canonical in
``router_profiles.yaml``. This file stores only the extra agent metadata,
graph layout and execution checkpoints which do not belong to routing.
"""
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:
"""Return the user-editable directory containing real Agent Files."""
directory = get_hermes_home() / "agents"
directory.mkdir(parents=True, exist_ok=True)
return directory
def get_compatibility_path() -> Path: def get_compatibility_path() -> Path:
return get_config_dir() / "compatibility.json" return get_config_dir() / "compatibility.json"

View file

@ -305,6 +305,23 @@ class ActionExecutor:
except Exception: except Exception:
pass pass
if action in {
'create_agent',
'update_agent',
'delete_agent',
'read_agent_file',
'save_agent_file',
'save_workflow',
'start_workflow',
'stop_workflow',
}:
try:
from antigravity_provider.router.workflow_service import execute_workflow_action
return execute_workflow_action(action, data)
except (ValueError, OSError) as exc:
return {'ok': False, 'message': str(exc)}
# Device-flow для Grok и Codex через веб. Backend был готов давно, но # Device-flow для Grok и Codex через веб. Backend был готов давно, но
# наружу не выведен: веб-мастер показывал заглушку «не реализовано», и # наружу не выведен: веб-мастер показывал заглушку «не реализовано», и
# подключить эти провайдеры можно было только из десктопа. Настоящие # подключить эти провайдеры можно было только из десктопа. Настоящие
@ -613,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

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
from antigravity_provider.router.role_registry import RoleRegistry
from antigravity_provider.router.router_config import ( from antigravity_provider.router.router_config import (
RolePolicy, RolePolicy,
RouterConfig, RouterConfig,
@ -25,19 +26,7 @@ from antigravity_provider.router.router_config import (
logger = logging.getLogger("hermes.router.auto_assigner") logger = logging.getLogger("hermes.router.auto_assigner")
HUMAN_ROLE_LABELS = { HUMAN_ROLE_LABELS = RoleRegistry.get_human_role_labels()
"orchestrator_primary": "Главный оркестратор",
"orchestrator_fallback": "Резервный оркестратор",
"coder_1": "Кодер 1",
"coder_2": "Кодер 2",
"reviewer": "Ревьюер",
"researcher": "Исследователь",
"fast_agent": "Быстрый агент",
"universal_subagent": "Универсальный субагент",
"spare_1": "Резерв 1",
"spare_2": "Резерв 2",
"cold_spare": "Холодный резерв",
}
DEFAULT_SLOT_ROLES = { DEFAULT_SLOT_ROLES = {
"codex-orch": ("Главный оркестратор", "orchestrator", "primary"), "codex-orch": ("Главный оркестратор", "orchestrator", "primary"),
@ -67,35 +56,7 @@ DEFAULT_SLOT_ROLES = {
} }
CANONICAL_ROLE_MAP = { CANONICAL_ROLE_MAP = RoleRegistry.get_canonical_role_map()
"orchestrator": "orchestrator",
"orchestrator_primary": "orchestrator",
"orchestrator_fallback": "orchestrator",
"главный оркестратор": "orchestrator",
"резервный оркестратор": "orchestrator",
"coder": "coder-primary",
"coder_1": "coder-primary",
"coder-1": "coder-primary",
"coder-primary": "coder-primary",
"кодер 1": "coder-primary",
"coder_2": "coder-secondary",
"coder-2": "coder-secondary",
"coder-secondary": "coder-secondary",
"кодер 2": "coder-secondary",
"reviewer": "reviewer",
"ревьюер": "reviewer",
"research": "research",
"researcher": "research",
"исследователь": "research",
"fast": "fast",
"fast_agent": "fast",
"быстрый агент": "fast",
"general": "fast",
"universal_subagent": "fast",
"универсальный субагент": "fast",
"tester": "fast",
"тестировщик": "fast",
}
class AutoAssigner: class AutoAssigner:
@ -172,7 +133,7 @@ class AutoAssigner:
candidates = list(provider_slots.get(provider_norm, [])) candidates = list(provider_slots.get(provider_norm, []))
# Priority based on requested role # Priority based on requested role
if requested_role == "orchestrator": if requested_role == "manager":
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates: if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
candidates.remove("codex-orch") candidates.remove("codex-orch")
candidates.insert(0, "codex-orch") candidates.insert(0, "codex-orch")
@ -368,7 +329,7 @@ class AutoAssigner:
"changes": [], "changes": [],
} }
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] canonical_roles = [r for r in RoleRegistry.get_role_ids() if RoleRegistry.is_role_implemented(r)]
changes: List[Dict[str, Any]] = [] changes: List[Dict[str, Any]] = []
# Ensure canonical roles exist in config # Ensure canonical roles exist in config
@ -418,12 +379,12 @@ class AutoAssigner:
else: else:
# Case 3: Multiple providers connected -> distribute by role provider preferences # Case 3: Multiple providers connected -> distribute by role provider preferences
role_provider_preferences = { role_provider_preferences = {
"orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"], "manager": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], "developer-1": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], "developer-2": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
"reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"], "code-reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
"research": ["opencode", "antigravity", "grok", "claude", "codex", "local"], "researcher": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
"fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"], "tester": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
} }
by_prov: Dict[str, List[str]] = {} by_prov: Dict[str, List[str]] = {}
@ -503,7 +464,7 @@ class AutoAssigner:
main_codex = ProfileAuthManager.get_main_profile("openai-codex") main_codex = ProfileAuthManager.get_main_profile("openai-codex")
team = { team = {
"orchestrator": [], "manager": [],
"subagents": [], "subagents": [],
"spares": [], "spares": [],
"summary": { "summary": {
@ -625,3 +586,19 @@ class AutoAssigner:
level="info", level="info",
) )
return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}" return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}"
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
"""Keep existing chain rank or route a newly introduced profile slot.
Lives outside the GUI wizard so hermetic tests can import it without customtkinter.
"""
config = load_router_config()
assigned_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"",
)
if assigned_role:
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")

View file

@ -417,6 +417,17 @@ class ModelRegistry:
normalized = role.strip().lower() normalized = role.strip().lower()
if normalized in self._role_reqs: if normalized in self._role_reqs:
return self._role_reqs[normalized] return self._role_reqs[normalized]
alias_map = {
"code-reviewer": "reviewer",
"manager": "orchestrator",
"developer-1": "coder-primary",
"developer-2": "coder-secondary",
"tester": "fast",
"researcher": "research",
}
mapped = alias_map.get(normalized)
if mapped and mapped in self._role_reqs:
return self._role_reqs[mapped]
# Default fallback for custom roles # Default fallback for custom roles
return RoleRequirements( return RoleRequirements(
role_id=normalized, role_id=normalized,

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

@ -0,0 +1,357 @@
"""Hermes Multi-Provider Account Router — Unified Role Registry."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from antigravity_provider.router.router_config import RolePolicy
STATUS_DECLARED_UNIMPLEMENTED = "DECLARED_UNIMPLEMENTED"
STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU = "Роль объявлена, исполнение не реализовано"
@dataclass(frozen=True)
class RoleDefinition:
role_id: str
display_name_ru: str
short_name_ru: str
description_ru: str
is_implemented: bool = True
unimplemented_reason_ru: Optional[str] = None
capabilities: List[str] = field(default_factory=list)
fallback_capabilities: List[str] = field(default_factory=list)
default_preferred_chain: List[str] = field(default_factory=list)
default_model: Optional[str] = None
max_failover_attempts: int = 3
session_affinity_enabled: bool = True
is_orchestrator: bool = False
tier: str = "core"
CANONICAL_ROLES: Dict[str, RoleDefinition] = {
"manager": RoleDefinition(
role_id="manager",
display_name_ru="Менеджер проекта (Оркестратор)",
short_name_ru="Менеджер",
description_ru="Координация работы субагентов, планирование, контроль исполнения и распределение задач.",
is_implemented=True,
is_orchestrator=True,
capabilities=["orchestrator", "reasoning", "management", "planning"],
fallback_capabilities=["orchestrator", "reasoning"],
default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"],
default_model="gemini-3.7-flash",
max_failover_attempts=3,
tier="core",
),
"developer-1": RoleDefinition(
role_id="developer-1",
display_name_ru="Ведущий разработчик (Кодер 1)",
short_name_ru="Кодер 1",
description_ru="Пишет код, реализует функционал, исправляет ошибки.",
is_implemented=True,
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
fallback_capabilities=["coding"],
default_preferred_chain=["codex-worker-1", "claude-worker-1", "ag-w1", "opengo-1"],
max_failover_attempts=3,
tier="core",
),
"developer-2": RoleDefinition(
role_id="developer-2",
display_name_ru="Вспомогательный разработчик (Кодер 2)",
short_name_ru="Кодер 2",
description_ru="Проверяет код Разработчика 1 и выдаёт ему задание на исправление.",
is_implemented=True,
capabilities=["coding", "developer-2", "reviewer", "tools"],
fallback_capabilities=["coding", "reviewer"],
default_preferred_chain=["ag-w1", "grok-worker-1", "codex-worker-2", "opengo-3"],
max_failover_attempts=3,
tier="core",
),
"code-reviewer": RoleDefinition(
role_id="code-reviewer",
display_name_ru="Ревьюер кода",
short_name_ru="Код-ревьювер",
description_ru="Анализирует код на ошибки, проблемы безопасности и соответствие стандартам. Работает после Разработчика 2.",
is_implemented=True,
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
fallback_capabilities=["reviewer", "coding"],
default_preferred_chain=["claude-worker-2", "codex-worker-2", "ag-w2", "opengo-2"],
max_failover_attempts=3,
tier="core",
),
"researcher": RoleDefinition(
role_id="researcher",
display_name_ru="Исследователь",
short_name_ru="Исследователь",
description_ru="Изучает данные, кодовую базу, документацию и внешние источники, чтобы собрать информацию для решения задачи.",
is_implemented=True,
capabilities=["researcher", "research", "search", "long_context"],
fallback_capabilities=["research", "search"],
default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"],
max_failover_attempts=3,
tier="core",
),
"tester": RoleDefinition(
role_id="tester",
display_name_ru="Тестировщик (QA)",
short_name_ru="Тестировщик",
description_ru="Создаёт тесты, проверяет корректность работы кода, находит дефекты.",
is_implemented=True,
capabilities=["tester", "testing", "fast", "automation"],
fallback_capabilities=["fast", "testing"],
default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"],
max_failover_attempts=3,
tier="qa_doc",
),
"tech-writer": RoleDefinition(
role_id="tech-writer",
display_name_ru="Технический писатель",
short_name_ru="Техписатель",
description_ru="Создаёт документацию, инструкции, README.",
is_implemented=True,
capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
fallback_capabilities=["documentation", "reasoning"],
default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"],
max_failover_attempts=3,
tier="qa_doc",
),
"analyst": RoleDefinition(
role_id="analyst",
display_name_ru="Системный аналитик",
short_name_ru="Аналитик",
description_ru="Проводит глубокий анализ данных, выявляет тренды, строит прогнозы.",
is_implemented=True,
capabilities=["analyst", "reasoning", "research", "planning"],
fallback_capabilities=["reasoning", "research"],
default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"],
max_failover_attempts=3,
tier="qa_doc",
),
"guardian": RoleDefinition(
role_id="guardian",
display_name_ru="Надзиратель (агент безопасности)",
short_name_ru="Надзиратель",
description_ru="Проверяет входящие инструкции на промпт-инъекции, анализирует планы и вызовы инструментов, блокирует обход системных правил, не допускает утечки секретов, следит за границами песочницы.",
is_implemented=False,
unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
capabilities=["guardian", "security", "sandboxing"],
fallback_capabilities=["guardian", "security"],
default_preferred_chain=[],
max_failover_attempts=0,
tier="governance",
),
"cost-controller": RoleDefinition(
role_id="cost-controller",
display_name_ru="Агент контроля затрат",
short_name_ru="Контроль затрат",
description_ru="Оценивает планируемый расход токенов, сравнивает с остатком бюджета, предлагает упрощения, сверяет факт с прогнозом, останавливает цепочку при исчерпании лимита.",
is_implemented=False,
unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
capabilities=["cost-controller", "budget", "analytics"],
fallback_capabilities=["cost-controller", "budget"],
default_preferred_chain=[],
max_failover_attempts=0,
tier="governance",
),
"integration-expert": RoleDefinition(
role_id="integration-expert",
display_name_ru="Специалист по интеграции",
short_name_ru="Интегратор",
description_ru="Работает с API и внешними сервисами, отправляет вебхуки.",
is_implemented=True,
capabilities=["integration-expert", "integration", "coding", "networking"],
fallback_capabilities=["integration", "coding"],
default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"],
max_failover_attempts=3,
tier="expert",
),
"security-expert": RoleDefinition(
role_id="security-expert",
display_name_ru="Юрист / специалист по безопасности",
short_name_ru="Безопасник",
description_ru="Проверяет код и данные на уязвимости.",
is_implemented=True,
capabilities=["security-expert", "security", "code-reviewer", "audit"],
fallback_capabilities=["security", "code-reviewer"],
default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"],
max_failover_attempts=3,
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] = {
"orchestrator": "manager",
"главный оркестратор": "manager",
"оркестратор": "manager",
"менеджер": "manager",
"coder": "developer-1",
"coder-primary": "developer-1",
"developer": "developer-1",
"кодер": "developer-1",
"кодер 1": "developer-1",
"разработчик": "developer-1",
"разработчик 1": "developer-1",
"coder-secondary": "developer-2",
"кодер 2": "developer-2",
"разработчик 2": "developer-2",
"reviewer": "code-reviewer",
"ревьюер": "code-reviewer",
"код-ревьювер": "code-reviewer",
"код-ревьюер": "code-reviewer",
"research": "researcher",
"исследователь": "researcher",
"fast": "tester",
"general": "tester",
"тестировщик": "tester",
"быстрый агент": "tester",
"tech_writer": "tech-writer",
"технический писатель": "tech-writer",
"аналитик": "analyst",
"надзиратель": "guardian",
"контроль затрат": "cost-controller",
"агент контроля затрат": "cost-controller",
"интеграция": "integration-expert",
"специалист по интеграции": "integration-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:
@classmethod
def get_all_roles(cls) -> Dict[str, RoleDefinition]:
return dict(CANONICAL_ROLES)
@classmethod
def get_role(cls, role_id: str) -> Optional[RoleDefinition]:
canonical_id = cls.resolve_canonical_role(role_id)
return CANONICAL_ROLES.get(canonical_id)
@classmethod
def get_role_ids(cls) -> List[str]:
return list(CANONICAL_ROLES.keys())
@classmethod
def get_executable_role_ids(cls) -> List[str]:
return [r_id for r_id, r_def in CANONICAL_ROLES.items() if r_def.is_implemented]
@classmethod
def get_unimplemented_role_ids(cls) -> List[str]:
return [r_id for r_id, r_def in CANONICAL_ROLES.items() if not r_def.is_implemented]
@classmethod
def is_role_implemented(cls, role_id: str) -> bool:
r_def = cls.get_role(role_id)
return r_def.is_implemented if r_def else False
@classmethod
def get_role_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
r_def = cls.get_role(role_id)
if r_def:
return r_def.display_name_ru
return default or role_id
@classmethod
def get_role_short_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
r_def = cls.get_role(role_id)
if r_def:
return r_def.short_name_ru
return default or role_id
@classmethod
def get_role_description_ru(cls, role_id: str) -> str:
r_def = cls.get_role(role_id)
if r_def:
return r_def.description_ru
return ""
@classmethod
def resolve_canonical_role(cls, name_or_alias: str) -> str:
if not name_or_alias:
return "manager"
clean = name_or_alias.strip().lower()
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
def get_canonical_role_map(cls) -> Dict[str, str]:
return dict(_CANONICAL_ROLE_ALIASES)
@classmethod
def get_human_role_labels(cls) -> Dict[str, str]:
labels = {}
for r_id, r_def in CANONICAL_ROLES.items():
labels[r_id] = r_def.short_name_ru
labels.update({
"spare_1": "Резерв 1",
"spare_2": "Резерв 2",
"cold_spare": "Холодный резерв",
})
return labels
@classmethod
def get_default_role_policies(cls) -> Dict[str, "RolePolicy"]:
from antigravity_provider.router.router_config import RolePolicy
policies: Dict[str, "RolePolicy"] = {}
for r_id, r_def in CANONICAL_ROLES.items():
policies[r_id] = RolePolicy(
role_name=r_id,
preferred_chain=list(r_def.default_preferred_chain),
fallback_capabilities=list(r_def.fallback_capabilities),
max_failover_attempts=r_def.max_failover_attempts,
session_affinity_enabled=r_def.session_affinity_enabled,
default_model=r_def.default_model,
)
return policies
@classmethod
def migrate_legacy_roles(cls, current_roles: Dict[str, "RolePolicy"]) -> Tuple[Dict[str, "RolePolicy"], bool]:
from antigravity_provider.router.router_config import RolePolicy
migrated: Dict[str, "RolePolicy"] = {}
was_modified = False
default_policies = cls.get_default_role_policies()
for rname, rpol in current_roles.items():
canonical_id = cls.resolve_canonical_role(rname)
if canonical_id != rname:
was_modified = True
if canonical_id not in migrated:
migrated[canonical_id] = RolePolicy(
role_name=canonical_id,
preferred_chain=list(rpol.preferred_chain),
fallback_capabilities=list(rpol.fallback_capabilities) or list(default_policies[canonical_id].fallback_capabilities),
max_failover_attempts=rpol.max_failover_attempts,
session_affinity_enabled=rpol.session_affinity_enabled,
default_model=rpol.default_model or default_policies[canonical_id].default_model,
)
else:
migrated[rname] = rpol
for canon_id, def_policy in default_policies.items():
if canon_id not in migrated:
migrated[canon_id] = def_policy
was_modified = True
return migrated, was_modified

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from antigravity_provider.router.role_registry import RoleRegistry
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import yaml import yaml
@ -258,7 +259,7 @@ def get_default_router_config() -> RouterConfig:
profile_id="local-1", profile_id="local-1",
provider="local", provider="local",
account_id="local-acc-1", account_id="local-acc-1",
capabilities=["reviewer", "coder-secondary", "reasoning", "coding"], capabilities=["code-reviewer", "reviewer", "coder-secondary", "reasoning", "coding"],
preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"], preferred_models=["Qwen3.8-27B-Q4_K_M.gguf", "default"],
max_concurrency=1, max_concurrency=1,
), ),
@ -266,57 +267,13 @@ def get_default_router_config() -> RouterConfig:
profile_id="local-2", profile_id="local-2",
provider="local", provider="local",
account_id="local-acc-2", account_id="local-acc-2",
capabilities=["fast", "research", "coding"], capabilities=["tester", "fast", "research", "coding"],
preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"], preferred_models=["Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "default"],
max_concurrency=1, max_concurrency=1,
), ),
} }
roles: dict[str, RolePolicy] = { roles = RoleRegistry.get_default_role_policies()
"orchestrator": RolePolicy(
role_name="orchestrator",
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
fallback_capabilities=["orchestrator", "reasoning"],
max_failover_attempts=3,
session_affinity_enabled=True,
default_model="gemini-3.7-flash",
),
"coder-primary": RolePolicy(
role_name="coder-primary",
preferred_chain=["codex-worker-1", "ag-w1", "opengo-3"],
fallback_capabilities=["coding"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"coder-secondary": RolePolicy(
role_name="coder-secondary",
preferred_chain=["codex-worker-2", "ag-w2", "opengo-2"],
fallback_capabilities=["coding", "reviewer"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"reviewer": RolePolicy(
role_name="reviewer",
preferred_chain=["codex-worker-2", "opengo-2", "ag-w2"],
fallback_capabilities=["reviewer", "coding"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"research": RolePolicy(
role_name="research",
preferred_chain=["opengo-1", "ag-w3", "ag-w4"],
fallback_capabilities=["research", "search"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
"fast": RolePolicy(
role_name="fast",
preferred_chain=["opengo-1", "ag-w4", "ag-spare-1"],
fallback_capabilities=["fast"],
max_failover_attempts=3,
session_affinity_enabled=True,
),
}
return RouterConfig( return RouterConfig(
enabled=True, enabled=True,
@ -346,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

@ -55,6 +55,7 @@ class HubSnapshot:
quotas: Dict[str, Any] quotas: Dict[str, Any]
metrics: Dict[str, Any] = field(default_factory=dict) metrics: Dict[str, Any] = field(default_factory=dict)
is_stale: bool = False is_stale: bool = False
workflow: Dict[str, Any] = field(default_factory=dict)
def get_profile(self, profile_id: str) -> Optional[ProfileViewModel]: def get_profile(self, profile_id: str) -> Optional[ProfileViewModel]:
return self.all_profiles.get(profile_id) return self.all_profiles.get(profile_id)
@ -107,6 +108,24 @@ class HubStateStore:
if self._current_snapshot is not None: if self._current_snapshot is not None:
if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale: if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale:
self._current_snapshot = replace(self._current_snapshot, is_stale=True) self._current_snapshot = replace(self._current_snapshot, is_stale=True)
# Provider/account scans are intentionally cached, while LIVE
# workflow checkpoints are small local state and must never lag
# behind an action until the next expensive provider refresh.
try:
from .workflow_service import WorkflowService
live_workflow = WorkflowService.get().snapshot()
role_views = {agent.role_id: agent for agent in self._current_snapshot.agents}
for workflow_agent in live_workflow.get("agents", []):
role_view = role_views.get(workflow_agent.get("role"))
generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
if role_view and workflow_agent.get("name") == generic_name:
workflow_agent["name"] = role_view.role_name_ru
if role_view and not workflow_agent.get("description"):
workflow_agent["description"] = role_view.role_description_ru
self._current_snapshot = replace(self._current_snapshot, workflow=live_workflow)
except Exception:
pass
return self._current_snapshot return self._current_snapshot
return self.refresh(force_scan=False) return self.refresh(force_scan=False)
@ -196,6 +215,27 @@ class HubStateStore:
"active_calls_total": active_leases_total, "active_calls_total": active_leases_total,
"active_calls_by_profile": active_leases_by_profile, "active_calls_by_profile": active_leases_by_profile,
} }
try:
from .workflow_service import WorkflowService
workflow_data = WorkflowService.get().snapshot()
role_views = {agent.role_id: agent for agent in agents}
for workflow_agent in workflow_data.get("agents", []):
role_view = role_views.get(workflow_agent.get("role"))
generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title()
if role_view and workflow_agent.get("name") == generic_name:
workflow_agent["name"] = role_view.role_name_ru
if role_view and not workflow_agent.get("description"):
workflow_agent["description"] = role_view.role_description_ru
except Exception as exc:
workflow_data = {
"agents": [],
"definition": {},
"run": {"status": "unavailable"},
"events": [],
"is_loading": False,
"unavailable_reason": f"Workflow state unavailable: {exc}",
}
snapshot = HubSnapshot( snapshot = HubSnapshot(
generation=gen, generation=gen,
seq=request_seq, seq=request_seq,
@ -209,6 +249,7 @@ class HubStateStore:
quotas=quotas_map, quotas=quotas_map,
metrics=metrics, metrics=metrics,
is_stale=False, is_stale=False,
workflow=workflow_data,
) )
self._current_snapshot = snapshot self._current_snapshot = snapshot
@ -263,6 +304,13 @@ class HubStateStore:
"active_calls_by_profile": {}, "active_calls_by_profile": {},
}, },
is_stale=True, is_stale=True,
workflow={
"agents": [],
"definition": {},
"run": {"status": "loading"},
"events": [],
"is_loading": True,
},
) )
def _apply_profile_delta(self, profile: ProfileViewModel) -> HubSnapshot: def _apply_profile_delta(self, profile: ProfileViewModel) -> HubSnapshot:

View file

@ -8,6 +8,7 @@ Captures, persists, and computes honest empirical measurements for all router ca
- Ring Buffer & File Rotation (bounded memory and disk footprint) - Ring Buffer & File Rotation (bounded memory and disk footprint)
""" """
from __future__ import annotations from __future__ import annotations
from antigravity_provider.router.role_registry import RoleRegistry
import collections import collections
import datetime import datetime
@ -30,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."""
@ -44,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
@ -68,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)
@ -78,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."""
@ -175,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,
@ -195,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,
@ -267,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"),
@ -317,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={},
@ -332,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
@ -357,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
@ -368,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,
@ -382,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),
@ -389,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,
@ -399,7 +529,7 @@ class TelemetryService:
global_aggs = self.get_aggregates(window_seconds=window_seconds) global_aggs = self.get_aggregates(window_seconds=window_seconds)
providers = set(known_providers or ["antigravity", "openai-codex", "opencode-go"]) providers = set(known_providers or ["antigravity", "openai-codex", "opencode-go"])
roles = set(known_roles or ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]) roles = set(known_roles or RoleRegistry.get_role_ids())
with self._lock: with self._lock:
for r in self._buffer: for r in self._buffer:

View file

@ -22,25 +22,12 @@ import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing
from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.unified_health import EventLogService from antigravity_provider.router.unified_health import EventLogService
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
"""Keep existing chain rank or route a newly introduced profile slot."""
config = load_router_config()
assigned_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"",
)
if assigned_role:
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
class AddAccountWizard(HubModal): class AddAccountWizard(HubModal):
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment.""" """4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""

View file

@ -127,7 +127,8 @@ def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -
node_set = set(node_ids) node_set = set(node_ids)
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}): for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id)) issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
if "orchestrator" not in node_set: orch_node = next((n for n in ("manager", "orchestrator") if n in node_set), None)
if not orch_node:
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора")) issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
for node in graph.nodes: for node in graph.nodes:
policy = config.roles.get(node.role_id) policy = config.roles.get(node.role_id)
@ -172,8 +173,8 @@ def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -
visit(target) visit(target)
active.remove(role_id) active.remove(role_id)
if "orchestrator" in node_set: if orch_node:
visit("orchestrator") visit(orch_node)
for role_id in sorted(node_set - visited): for role_id in sorted(node_set - visited):
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id)) issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
return issues return issues

View file

@ -9,6 +9,7 @@ Single Source of Truth for:
- EventLogService - EventLogService
""" """
from __future__ import annotations from __future__ import annotations
from antigravity_provider.router.role_registry import RoleRegistry
import json import json
import logging import logging
@ -644,21 +645,38 @@ class UnifiedHealthService:
config = load_router_config() config = load_router_config()
self.scan_all(force=False) self.scan_all(force=False)
ROLE_META = {
"orchestrator": ("Главный оркестратор", "Управление командой, планирование, контроль исполнения"),
"coder-primary": ("Кодер 1", "Основная разработка кода и исправление дефектов"),
"coder-secondary": ("Кодер 2", "Параллельная разработка и вспомогательные модули"),
"reviewer": ("Ревьюер", "Независимое fail-closed ревью и валидация diff"),
"research": ("Исследователь", "Read-only поиск в кодовой базе и сбор фактов"),
"fast": ("Быстрый агент", "Оперативные вызовы, вспомогательные проверки"),
"universal": ("Универсальный агент", "Широкий спектр общих задач"),
}
agents: List[AgentViewModel] = [] agents: List[AgentViewModel] = []
for rname, rpol in config.roles.items(): for rname, rpol in config.roles.items():
rname_ru, rdesc_ru = ROLE_META.get(rname, (rname, "")) rname_ru = RoleRegistry.get_role_name_ru(rname, rname)
rdesc_ru = RoleRegistry.get_role_description_ru(rname)
is_implemented = RoleRegistry.is_role_implemented(rname)
chain = rpol.preferred_chain chain = rpol.preferred_chain
if not is_implemented:
agents.append(AgentViewModel(
role_id=rname,
role_name_ru=rname_ru,
role_description_ru=rdesc_ru or "Роль объявлена, исполнение не реализовано",
assigned_profile_id=None,
assigned_display_name=None,
provider="N/A",
provider_display_name="N/A",
model="-",
account_identity="Роль объявлена, исполнение не реализовано",
routing_position="Отключено",
status="unimplemented",
status_label_ru="Не реализовано",
is_active=False,
is_main_orchestrator=False,
cooldown_remaining_sec=0,
session_id=None,
active_quota_status="unavailable",
active_quota_label="Не применяется (роль не активна)",
))
continue
if not chain: if not chain:
continue continue
@ -703,7 +721,7 @@ class UnifiedHealthService:
status=active_pvm.health_state, status=active_pvm.health_state,
status_label_ru=active_pvm.health_label_ru, status_label_ru=active_pvm.health_label_ru,
is_active=(active_pvm.health_state == STATUS_HEALTHY), is_active=(active_pvm.health_state == STATUS_HEALTHY),
is_main_orchestrator=(rname == "orchestrator"), is_main_orchestrator=(rname == "manager"),
cooldown_remaining_sec=active_pvm.cooldown_remaining_sec, cooldown_remaining_sec=active_pvm.cooldown_remaining_sec,
session_id=None, session_id=None,
active_quota_status=active_quota_st, active_quota_status=active_quota_st,
@ -772,15 +790,7 @@ class UnifiedHealthService:
self.scan_all(force=False) self.scan_all(force=False)
pipelines: Dict[str, RolePipeline] = {} pipelines: Dict[str, RolePipeline] = {}
ROLE_NAMES = {
"orchestrator": "Главный оркестратор",
"coder-primary": "Кодер 1 (Primary)",
"coder-secondary": "Кодер 2 (Secondary)",
"reviewer": "Ревьюер",
"research": "Исследователь",
"fast": "Быстрый агент",
"universal": "Универсальный агент",
}
for rname, rpol in config.roles.items(): for rname, rpol in config.roles.items():
nodes: List[PipelineNode] = [] nodes: List[PipelineNode] = []
@ -834,7 +844,7 @@ class UnifiedHealthService:
pipelines[rname] = RolePipeline( pipelines[rname] = RolePipeline(
role_id=rname, role_id=rname,
role_name_ru=ROLE_NAMES.get(rname, rname), role_name_ru=RoleRegistry.get_role_name_ru(rname, rname),
default_model=rpol.default_model or "auto", default_model=rpol.default_model or "auto",
max_failover=rpol.max_failover_attempts, max_failover=rpol.max_failover_attempts,
session_affinity=rpol.session_affinity_enabled, session_affinity=rpol.session_affinity_enabled,

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()

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Hub — Панель управления</title> <title>Hermes Hub — Панель управления</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="static/workflow.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
</head> </head>
<body> <body>
@ -92,52 +93,53 @@
<div class="content-scroll"> <div class="content-scroll">
<!-- 1. OVERVIEW VIEW --> <!-- 1. OVERVIEW VIEW -->
<section id="view-overview" class="view-pane active"> <section id="view-overview" class="view-pane active">
<div class="overview-grid"> <div class="workflow-toolbar">
<div class="kpi-card"> <div class="workflow-mode" role="group" aria-label="Режим workflow">
<div class="kpi-label">Состояние системы</div> <button class="workflow-mode-btn active" id="workflow-mode-live" data-mode="live">LIVE</button>
<div class="kpi-value text-healthy" id="kpi-system-readiness"></div> <button class="workflow-mode-btn" id="workflow-mode-edit" data-mode="edit">EDIT</button>
<div class="kpi-sub" id="kpi-readiness-summary"></div>
</div> </div>
<div class="kpi-card"> <div class="workflow-toolbar-actions">
<div class="kpi-label">Подключено аккаунтов</div> <span class="workflow-save-state" id="workflow-save-state">Сохранено</span>
<div class="kpi-value text-accent" id="kpi-total-accounts"></div> <button class="btn btn-secondary" id="btn-workflow-save">Сохранить workflow</button>
<div class="kpi-sub" id="kpi-accounts-sub"></div> <button class="btn btn-primary" id="btn-agent-add">+ Добавить агента</button>
</div>
<div class="kpi-card">
<div class="kpi-label">Готовых ролей</div>
<div class="kpi-value text-healthy" id="kpi-ready-roles"></div>
<div class="kpi-sub" id="kpi-roles-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Провайдеры ИИ</div>
<div class="kpi-value text-info" id="kpi-providers-count"></div>
<div class="kpi-sub" id="kpi-providers-sub">5 поддерживаемых систем</div>
</div> </div>
</div> </div>
<div class="section-card"> <div class="workflow-kpis" aria-label="Оперативные показатели">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;"> <article class="workflow-kpi"><span>Активные задачи</span><strong id="workflow-kpi-active">Загрузка…</strong><small id="workflow-kpi-active-reason">Получение снапшота</small></article>
<div> <article class="workflow-kpi"><span>Агенты онлайн</span><strong id="workflow-kpi-online">Загрузка…</strong><small id="workflow-kpi-online-reason">Получение снапшота</small></article>
<div class="section-card-title">Схема маршрутизации запросов</div> <article class="workflow-kpi"><span>Среднее время ответа</span><strong id="workflow-kpi-latency">Загрузка…</strong><small id="workflow-kpi-latency-reason">Получение телеметрии</small></article>
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div> <article class="workflow-kpi"><span>Использование токенов</span><strong id="workflow-kpi-tokens">Загрузка…</strong><small id="workflow-kpi-tokens-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Успешность задач</span><strong id="workflow-kpi-success">Загрузка…</strong><small id="workflow-kpi-success-reason">Получение телеметрии</small></article>
</div>
<div class="workflow-main-layout">
<section class="workflow-board-card">
<header class="workflow-board-header">
<div><strong id="workflow-title">Workflow</strong> <span id="workflow-run-state" class="workflow-run-state">Загрузка…</span></div>
<label>Итерация: <strong id="workflow-iteration">Н</strong> / <input id="workflow-max-iterations" type="number" min="1" max="100" value="5"></label>
<div class="workflow-zoom"><button id="workflow-zoom-out" title="Уменьшить"></button><span id="workflow-zoom-value">100%</span><button id="workflow-zoom-in" title="Увеличить">+</button><button id="workflow-fit" title="Вписать граф"></button></div>
</header>
<div class="workflow-canvas" id="workflow-canvas" tabindex="0" aria-label="Редактор графа workflow">
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"></path></marker></defs><g id="workflow-edge-layer"></g></svg>
<div id="workflow-node-layer" class="workflow-node-layer"></div>
<div id="workflow-empty" class="workflow-empty hidden"><strong>В workflow пока нет агентов</strong><span>Добавьте агента, затем переключитесь в EDIT и соедините узлы.</span></div>
<div class="workflow-minimap" id="workflow-minimap" aria-label="Мини-карта"></div>
</div> </div>
<button class="btn btn-secondary btn-sm" id="btn-auto-assign" title="Автоматическое распределение аккаунтов по ролям"> <footer class="workflow-legend">
Авто-распределение <span><i class="state-waiting"></i>Ожидает</span><span><i class="state-working"></i>Работает</span><span><i class="state-reviewing"></i>Проверяет</span><span><i class="state-error"></i>Ошибка</span><span><i class="state-completed"></i>Завершено</span>
</button> <span class="edge-legend success">→ Успех</span><span class="edge-legend return">⇢ Возврат</span>
</div> </footer>
<div class="route-diagram-container" id="overview-route-diagram"> </section>
<!-- Rendered by app.js -->
</div> <aside class="workflow-inspector" id="workflow-inspector">
<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>
</aside>
</div> </div>
<div class="section-card"> <div class="workflow-bottom-grid">
<div class="section-card-header"> <section class="workflow-events"><header><strong>Последние события</strong><button id="workflow-events-all">Журнал событий →</button></header><div id="workflow-events-list" class="workflow-events-list"><p>Загрузка событий…</p></div></section>
<div class="section-card-title">Провайдеры и доступность runtime</div> <section class="workflow-run-panel"><header><strong>Управление LIVE</strong></header><textarea id="workflow-task" placeholder="Опишите реальную задачу для workflow"></textarea><div><button class="btn btn-primary" id="btn-workflow-start">Запустить workflow</button><button class="btn btn-secondary" id="btn-workflow-stop">Остановить</button></div><p id="workflow-run-error" class="workflow-run-error"></p></section>
<div class="section-card-subtitle">Статус локальных адаптеров и обнаруженные модели</div>
</div>
<div class="providers-grid" id="overview-providers-summary">
<!-- Rendered by app.js -->
</div>
</div> </div>
</section> </section>
@ -177,14 +179,48 @@
</section> </section>
<!-- 3. ROUTING VIEW (Main Control Center) --> <!-- 3. ROUTING VIEW (Main Control Center) -->
<section id="view-routing" class="view-pane">
<div class="view-header-note"> <section id="view-routing" class="view-pane">
Главный центр управления маршрутизацией: перетаскивайте узлы для смены приоритета (Основной → Резерв 1 → Резерв 2), настраивайте рабочие модели и управляйте составом цепочек. <div class="view-header-note" style="display:flex; justify-content: space-between; align-items: center;">
</div> <div>
<div class="routing-pipelines-list" id="routing-pipelines-container"> <i class="fa-solid fa-circle-info" style="color:var(--status-warning);"></i>
<!-- Rendered by app.js --> Перетащите аккаунт из правой панели в нужную роль и установите порядок (приоритет) использования.
</div> <br>
</section> Система будет использовать аккаунты сверху вниз по списку при выполнении задач с учётом квот и состояния.
</div>
</div>
<div class="routing-grid" style="display: flex; gap: 24px; height: calc(100vh - 150px);">
<!-- LEFT COLUMN: Roles -->
<div class="routing-left-col" id="routing-roles-container" style="flex: 2; overflow-y: auto; padding-right: 8px;">
<!-- Rendered by app.js -->
</div>
<!-- RIGHT COLUMN: Available Accounts -->
<div class="routing-right-col" style="flex: 1; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-md); background-color: var(--surface-muted);">
<div style="padding: 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
<h3 style="font-family: var(--font-title); font-size: 14px; text-transform: uppercase;">Доступные аккаунты</h3>
<span id="available-accounts-count" class="text-muted" style="font-size: 12px;">0 аккаунтов</span>
</div>
<div class="input-group">
<input type="text" id="routing-account-search" placeholder="Поиск по аккаунтам, ролям или моделям..." style="width: 100%;">
</div>
<div style="display: flex; gap: 8px; margin-top: 8px;">
<select id="routing-filter-provider" style="flex:1;"><option value="all">Все провайдеры</option></select>
<select id="routing-filter-status" style="flex:1;"><option value="all">Все статусы</option></select>
</div>
</div>
<div class="available-accounts-list" id="routing-available-container" style="flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;">
<!-- Rendered by app.js -->
</div>
<div style="padding: 16px; text-align: center; font-size: 12px; color: var(--text-muted); border-top: 1px dashed var(--border-subtle);">
<i class="fa-solid fa-arrows-up-down-left-right"></i> Перетащите аккаунт в нужную роль или нажмите «+» для добавления
</div>
</div>
</div>
</section>
<!-- 4. ANALYTICS VIEW (P0-1 & P0-5) --> <!-- 4. ANALYTICS VIEW (P0-1 & P0-5) -->
<section id="view-analytics" class="view-pane"> <section id="view-analytics" class="view-pane">
@ -241,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">
@ -366,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>
@ -505,5 +568,15 @@
<!-- Application Logic --> <!-- Application Logic -->
<script src="app.js"></script> <script src="app.js"></script>
<script src="static/workflow.js"></script>
<script>
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme) {
document.body.setAttribute('data-theme', theme);
const sel = document.getElementById('theme-selector');
if (sel) sel.value = theme;
}
</script>
</body> </body>
</html> </html>

View file

@ -1,33 +1,7 @@
/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */
/* Theme definitions according to A29 Design System "Крона" */
:root { :root {
--bg-base: #061916; /* Common variables */
--bg-sidebar: #08221E;
--bg-header: #071B18;
--bg-statusbar: #061512;
--bg-modal-backdrop: rgba(2, 11, 9, 0.85);
--surface: #0B2520;
--surface-hover: #12342C;
--surface-active: #194535;
--surface-selected: #173B2E;
--surface-muted: #091E1A;
--border: #36513B;
--border-subtle: #203A2D;
--border-accent: #B78525;
--border-hover: #537456;
--text-primary: #F8F0DC;
--text-secondary: #D1C7AE;
--text-muted: #8FA395;
--text-accent: #E0B84E;
--accent: #C89A2B;
--accent-hover: #E0B84E;
--accent-pressed: #A9781E;
--accent-dim: rgba(200, 154, 43, 0.15);
--status-healthy: #72C943; --status-healthy: #72C943;
--status-warning: #E1A62B; --status-warning: #E1A62B;
--status-error: #E45C4F; --status-error: #E45C4F;
@ -44,14 +18,107 @@
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 8px; --radius-lg: 8px;
--font-ui: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif; --font-ui: 'Inter', "Segoe UI", -apple-system, sans-serif;
--font-title: "Cinzel", "Segoe UI", serif, sans-serif; --font-title: "Cinzel", "Cinzel Decorative", "Segoe UI", serif;
--font-mono: "Consolas", "Courier New", monospace; --font-mono: "Consolas", "Courier New", monospace;
--header-height: 58px; --header-height: 58px;
--sidebar-width: 230px; --sidebar-width: 230px;
} }
/* Default DARK theme */
:root, body[data-theme="dark"] {
--bg-base: #101510;
--bg-sidebar: #0D120D;
--bg-header: #0D120D;
--bg-statusbar: #0D120D;
--bg-modal-backdrop: rgba(16, 21, 16, 0.85);
--surface: #1A2A1F;
--surface-hover: #2F4A36;
--surface-active: #3C5C44;
--surface-selected: #28402F;
--surface-muted: #152219;
--border: #36513B;
--border-subtle: #203A2D;
--border-accent: #CDAA64;
--border-hover: #537456;
--text-primary: #F7F1E3;
--text-secondary: #B0B8B2;
--text-muted: #8FA395;
--text-accent: #CDAA64;
--accent: #CDAA64;
--accent-hover: #E0B84E;
--accent-pressed: #A9781E;
--accent-dim: rgba(205, 170, 100, 0.15);
}
/* MEDIUM theme (cream cards on green canvas) */
body[data-theme="medium"] {
--bg-base: #1A2A1F;
--bg-sidebar: #152219;
--bg-header: #152219;
--bg-statusbar: #152219;
--bg-modal-backdrop: rgba(26, 42, 31, 0.85);
--surface: #F7F1E3;
--surface-hover: #F0EAD6;
--surface-active: #E6DFCB;
--surface-selected: #EAE3CF;
--surface-muted: #2F4A36;
--border: #D1C7AE;
--border-subtle: #E0D7BF;
--border-accent: #CDAA64;
--border-hover: #B7A88D;
--text-primary: #101510;
--text-secondary: #2F4A36;
--text-muted: #537456;
--text-accent: #B78525;
--accent: #B78525;
--accent-hover: #CDAA64;
--accent-pressed: #9A6F1D;
--accent-dim: rgba(183, 133, 37, 0.15);
}
/* LIGHT theme */
body[data-theme="light"] {
--bg-base: #F7F1E3;
--bg-sidebar: #F0EAD6;
--bg-header: #F0EAD6;
--bg-statusbar: #E6DFCB;
--bg-modal-backdrop: rgba(247, 241, 227, 0.85);
--surface: #FFFFFF;
--surface-hover: #FDFBF7;
--surface-active: #F4EEDF;
--surface-selected: #FAF7F0;
--surface-muted: #F0EAD6;
--border: #D1C7AE;
--border-subtle: #EAE3CF;
--border-accent: #CDAA64;
--border-hover: #C5BAA1;
--text-primary: #101510;
--text-secondary: #2F4A36;
--text-muted: #537456;
--text-accent: #B78525;
--accent: #B78525;
--accent-hover: #CDAA64;
--accent-pressed: #9A6F1D;
--accent-dim: rgba(183, 133, 37, 0.15);
}
/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@ -1559,20 +1626,114 @@ body {
} }
/* ── Light Theme Override ── */ /* ── Light Theme Override ── */
body[data-theme="light"], body.theme-light {
--bg-base: #F4F6F8;
--bg-sidebar: #E9ECEF; /* Drag and Drop Styles */
--bg-header: #FFFFFF; .draggable-item {
--bg-statusbar: #DEE2E6; cursor: grab;
--surface: #FFFFFF; user-select: none;
--surface-hover: #F1F3F5; }
--surface-active: #E9ECEF; .draggable-item:active {
--surface-selected: #E2E6EA; cursor: grabbing;
--surface-muted: #F8F9FA; }
--border: #CED4DA; .drop-zone {
--border-subtle: #DEE2E6; min-height: 40px;
--border-accent: #B78525; border: 1px dashed var(--border);
--text-primary: #212529; border-radius: var(--radius-sm);
--text-secondary: #495057; display: flex;
--text-muted: #6C757D; align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-muted);
transition: all 0.2s;
margin-top: 8px;
}
.drop-zone.drag-over {
border-color: var(--accent);
background-color: var(--accent-dim);
color: var(--accent);
}
.role-section {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: 16px;
display: flex;
flex-direction: column;
}
.role-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.role-header h4 {
font-family: var(--font-title);
color: var(--text-primary);
font-size: 14px;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.role-badge {
font-size: 10px;
background-color: var(--accent-dim);
color: var(--accent);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-ui);
}
.account-row {
display: grid;
grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
background-color: var(--surface);
font-size: 12px;
}
.account-row:last-child {
border-bottom: none;
}
.account-row.drag-over {
border-top: 2px solid var(--accent);
}
.drag-handle {
color: var(--text-muted);
cursor: grab;
text-align: center;
}
.provider-logo-sm {
width: 16px;
height: 16px;
border-radius: 2px;
}
.available-account-card {
background-color: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
cursor: grab;
}
.available-account-card:hover {
border-color: var(--border);
background-color: var(--surface-hover);
}
.grid-header {
display: grid;
grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border-subtle);
background-color: var(--surface-muted);
} }

View file

@ -0,0 +1,89 @@
/* A30 overview/workflow components. Theme colors come from the shared tokens. */
#view-overview { padding: 0; min-width: 860px; }
.workflow-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
.workflow-mode { display:flex; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-muted); }
.workflow-mode-btn { min-width:78px; border:0; border-radius:var(--radius-sm); padding:7px 16px; color:var(--text-muted); background:transparent; cursor:pointer; font-weight:700; }
.workflow-mode-btn.active { color:var(--text-primary); background:var(--surface-active); box-shadow:inset 0 0 0 1px var(--border-accent); }
.workflow-mode-btn:first-child.active { color:var(--status-healthy); }
.workflow-toolbar-actions { display:flex; gap:8px; align-items:center; }
.workflow-save-state { color:var(--text-muted); font-size:11px; }
.workflow-save-state.dirty { color:var(--status-warning); }
.workflow-kpis { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:8px; margin-bottom:10px; }
.workflow-kpi { padding:11px 13px; min-height:78px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:linear-gradient(145deg,var(--surface),var(--surface-muted)); }
.workflow-kpi span,.workflow-kpi small { display:block; color:var(--text-muted); }
.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:20px; font-weight:650; color:var(--text-primary); }
.workflow-kpi small { font-size:10px; line-height:1.25; }
.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:520px; }
.workflow-board-card,.workflow-inspector,.workflow-events,.workflow-run-panel { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; }
.workflow-board-header { min-height:45px; padding:8px 12px; display:flex; gap:18px; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); }
.workflow-board-header strong { color:var(--text-accent); text-transform:uppercase; }
.workflow-board-header label { margin-left:auto; font-size:11px; color:var(--text-muted); }
.workflow-board-header input { width:42px; padding:3px; color:var(--text-primary); background:var(--surface-muted); border:1px solid var(--border); border-radius:3px; }
.workflow-run-state { margin-left:8px; font-size:11px; color:var(--text-muted); }
.workflow-run-state.running { color:var(--status-healthy); }
.workflow-run-state.failed,.workflow-run-state.interrupted { color:var(--status-error); }
.workflow-zoom { display:flex; align-items:center; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.workflow-zoom button { width:28px; height:27px; border:0; border-left:1px solid var(--border-subtle); color:var(--text-accent); background:transparent; cursor:pointer; }
.workflow-zoom span { min-width:44px; text-align:center; font-size:11px; }
.workflow-canvas { position:relative; height:430px; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; }
.workflow-edges,.workflow-node-layer { position:absolute; inset:0; width:100%; height:100%; transform-origin:0 0; }
.workflow-edges { overflow:visible; pointer-events:none; }
.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); }
.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); }
.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; }
.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; }
.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; }
.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; }
.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); }
.workflow-node.working { border-color:var(--status-healthy); box-shadow:0 0 20px rgba(114,201,67,.24); }
.workflow-node.error { border-color:var(--status-error); }
.workflow-node.completed { border-color:var(--status-disabled); }
.workflow-node h3 { margin:0 0 2px; font-size:13px; font-weight:650; color:var(--text-primary); }
.workflow-node p { overflow:hidden; margin:1px 0; color:var(--text-muted); font-size:10px; white-space:nowrap; text-overflow:ellipsis; }
.workflow-node-status { display:flex; align-items:center; gap:5px; margin-top:6px; font-size:10px; color:var(--text-secondary); }
.workflow-node-status i,.workflow-legend i { width:7px; height:7px; border-radius:50%; background:var(--status-info); }
.workflow-node.working .workflow-node-status i,.state-working { background:var(--status-healthy)!important; }
.workflow-node.reviewing .workflow-node-status i,.state-reviewing { background:var(--status-warning)!important; }
.workflow-node.error .workflow-node-status i,.state-error { background:var(--status-error)!important; }
.workflow-node.completed .workflow-node-status i,.state-completed { background:var(--status-disabled)!important; }
.workflow-port { display:none; position:absolute; top:37px; width:13px; height:22px; border:1px solid var(--accent); border-radius:8px; background:var(--surface-active); }
.workflow-mode-edit .workflow-port { display:block; }
.workflow-port.in { left:-8px; }
.workflow-port.out { right:-8px; cursor:crosshair; }
.workflow-legend { min-height:39px; padding:8px 12px; display:flex; align-items:center; gap:14px; flex-wrap:wrap; border-top:1px solid var(--border-subtle); color:var(--text-muted); font-size:10px; }
.workflow-legend span { display:flex; align-items:center; gap:5px; }
.edge-legend.success { color:var(--status-healthy); }.edge-legend.return { color:var(--status-error); }
.workflow-minimap { position:absolute; left:10px; bottom:10px; width:110px; height:68px; border:1px solid var(--border); background:rgba(6,25,22,.82); pointer-events:none; }
.workflow-minimap i { position:absolute; width:16px; height:9px; border:1px solid var(--accent); background:var(--surface-muted); }
.workflow-empty { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:6px; color:var(--text-muted); }
.workflow-inspector { min-width:0; padding:12px; overflow:auto; max-height:520px; }
.workflow-inspector-empty { height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:8px; color:var(--text-muted); }
.workflow-inspector h2 { margin:0; color:var(--text-accent); font:600 15px var(--font-title); }
.inspector-tabs { display:flex; gap:2px; margin:10px 0; border-bottom:1px solid var(--border-subtle); overflow-x:auto; }
.inspector-tabs button { padding:7px 6px; border:0; border-bottom:2px solid transparent; color:var(--text-muted); background:transparent; font-size:10px; cursor:pointer; }
.inspector-tabs button.active { color:var(--text-accent); border-color:var(--accent); }
.inspector-field { display:block; margin:9px 0; color:var(--text-muted); font-size:10px; }
.inspector-field input,.inspector-field select,.inspector-field textarea,.workflow-run-panel textarea,.workflow-dialog input,.workflow-dialog select,.workflow-dialog textarea { width:100%; margin-top:4px; padding:7px 8px; color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-muted); font:12px var(--font-ui); }
.inspector-field textarea { min-height:58px; resize:vertical; }
.inspector-value { padding:7px 0; color:var(--text-secondary); font-size:11px; overflow-wrap:anywhere; }
.inspector-actions { display:flex; gap:7px; flex-wrap:wrap; margin-top:12px; }
.agent-file-card { margin-top:12px; padding:10px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.agent-file-card code { display:block; margin:4px 0 8px; color:var(--text-secondary); overflow-wrap:anywhere; }
.workflow-bottom-grid { display:grid; grid-template-columns:1.4fr 1fr; gap:10px; margin-top:10px; }
.workflow-events,.workflow-run-panel { min-height:145px; padding:11px; }
.workflow-events header,.workflow-run-panel header { display:flex; justify-content:space-between; margin-bottom:8px; color:var(--text-secondary); }
.workflow-events header button { border:0; color:var(--text-accent); background:none; cursor:pointer; }
.workflow-events-list { max-height:110px; overflow:auto; }
.workflow-event { display:grid; grid-template-columns:72px 1fr auto; gap:8px; padding:5px 0; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:10px; }
.workflow-event time,.workflow-event em { color:var(--text-muted); font-style:normal; }
.workflow-event.error { color:var(--status-error); }
.workflow-run-panel textarea { min-height:68px; resize:vertical; }
.workflow-run-panel>div { display:flex; gap:8px; margin-top:8px; }
.workflow-run-error { margin-top:6px; color:var(--status-error); font-size:10px; }
.workflow-dialog-backdrop { position:fixed; inset:0; z-index:1200; display:flex; justify-content:center; align-items:center; padding:24px; background:var(--bg-modal-backdrop); }
.workflow-dialog { width:min(580px,100%); max-height:88vh; overflow:auto; padding:18px; border:1px solid var(--border-accent); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 24px 70px rgba(0,0,0,.45); }
.workflow-dialog h2 { margin-bottom:12px; color:var(--text-accent); }
.workflow-dialog textarea.agent-file-editor { min-height:380px; font-family:var(--font-mono); }
.workflow-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; }
.hidden { display:none!important; }
@media (max-width:1200px) { .workflow-kpis { grid-template-columns:repeat(3,1fr); }.workflow-main-layout { grid-template-columns:minmax(560px,1fr) 290px; } }

View file

@ -0,0 +1,497 @@
/* Hermes Hub A30 workflow canvas — vanilla JS, no build step. */
'use strict';
const workflowUi = {
initialized: false,
mode: 'live',
selectedAgentId: null,
selectedTab: 'main',
scale: 1,
dirty: false,
draftEdges: [],
draftPositions: {},
connectingFrom: null,
drag: null,
};
function wfEscape(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;',
})[char]);
}
function wfValue(value, suffix = '') {
return value === null || value === undefined ? null : `${value}${suffix}`;
}
function wfUnavailable(elementId, reason) {
const value = document.getElementById(elementId);
const detail = document.getElementById(`${elementId}-reason`);
if (value) value.textContent = 'Н/Д';
if (detail) detail.textContent = `Н/Д: ${reason}`;
}
function initWorkflowOverview() {
if (workflowUi.initialized) return;
workflowUi.initialized = true;
document.getElementById('workflow-mode-live')?.addEventListener('click', () => setWorkflowMode('live'));
document.getElementById('workflow-mode-edit')?.addEventListener('click', () => setWorkflowMode('edit'));
document.getElementById('btn-workflow-save')?.addEventListener('click', saveWorkflowDraft);
document.getElementById('btn-agent-add')?.addEventListener('click', openAgentCreateDialog);
document.getElementById('btn-workflow-start')?.addEventListener('click', startWorkflowRun);
document.getElementById('btn-workflow-stop')?.addEventListener('click', () => executeAction('stop_workflow', {}));
document.getElementById('workflow-events-all')?.addEventListener('click', () => switchView('logs'));
document.getElementById('workflow-max-iterations')?.addEventListener('change', markWorkflowDirty);
document.getElementById('workflow-zoom-in')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale + 0.1));
document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1));
document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph);
const canvas = document.getElementById('workflow-canvas');
canvas?.addEventListener('mousemove', workflowPointerMove);
canvas?.addEventListener('mouseup', workflowPointerUp);
canvas?.addEventListener('mouseleave', workflowPointerCancel);
window.addEventListener('resize', drawWorkflowEdges);
}
function renderWorkflowOverview(snapshot) {
initWorkflowOverview();
const workflow = snapshot?.workflow;
if (!workflow) {
renderWorkflowLoading('Backend не вернул поле workflow');
return;
}
if (workflow.is_loading) {
renderWorkflowLoading('Workflow загружается');
return;
}
if (workflow.unavailable_reason) {
renderWorkflowLoading(workflow.unavailable_reason);
return;
}
if (!workflowUi.dirty && !workflowUi.drag && !workflowUi.connectingFrom) {
workflowUi.draftEdges = (workflow.definition?.edges || []).map((edge) => ({ ...edge }));
workflowUi.draftPositions = Object.fromEntries((workflow.agents || []).map((agent) => [
agent.id, { ...(agent.position || { x: 80, y: 80 }) },
]));
}
renderWorkflowKpis(snapshot);
renderWorkflowHeader(workflow);
renderWorkflowNodes(workflow);
renderWorkflowInspector(snapshot, workflow);
renderWorkflowEvents(workflow.events || []);
requestAnimationFrame(drawWorkflowEdges);
}
function renderWorkflowLoading(reason) {
['workflow-kpi-active', 'workflow-kpi-online', 'workflow-kpi-latency', 'workflow-kpi-tokens', 'workflow-kpi-success']
.forEach((id) => wfUnavailable(id, reason));
const empty = document.getElementById('workflow-empty');
if (empty) {
empty.classList.remove('hidden');
empty.innerHTML = `<strong>Workflow недоступен</strong><span>${wfEscape(reason)}</span>`;
}
}
function renderWorkflowKpis(snapshot) {
const metrics = snapshot.metrics || {};
const telemetry = metrics.telemetry || {};
const global = telemetry.global || {};
const workflow = snapshot.workflow || {};
const run = workflow.run || {};
const active = run.status === 'running' ? 1 : 0;
setWorkflowKpi('workflow-kpi-active', String(active), 'Источник: workflow.run.status');
const readiness = snapshot.readiness || {};
if (readiness.roles_ready_count === null || readiness.roles_ready_count === undefined || readiness.total_roles === undefined) {
wfUnavailable('workflow-kpi-online', 'readiness не содержит число готовых ролей');
} else {
setWorkflowKpi('workflow-kpi-online', `${readiness.roles_ready_count} / ${readiness.total_roles}`, 'Источник: readiness');
}
if (!global.total_calls) {
wfUnavailable('workflow-kpi-latency', 'за 24 часа нет измеренных вызовов');
wfUnavailable('workflow-kpi-success', 'за 24 часа нет завершённых вызовов');
} else {
const latency = wfValue(global.latency_p50_ms, ' мс');
latency ? setWorkflowKpi('workflow-kpi-latency', latency, 'Медиана p50, telemetry') : wfUnavailable('workflow-kpi-latency', 'провайдер не вернул задержку');
const success = global.successful_calls / global.total_calls * 100;
setWorkflowKpi('workflow-kpi-success', `${success.toFixed(1)}%`, `${global.successful_calls} из ${global.total_calls}, telemetry`);
}
if (global.total_tokens === null || global.total_tokens === undefined) {
wfUnavailable('workflow-kpi-tokens', 'провайдеры не вернули usage');
} else {
setWorkflowKpi('workflow-kpi-tokens', new Intl.NumberFormat('ru-RU').format(global.total_tokens), 'Источник: telemetry usage, 24 ч');
}
}
function setWorkflowKpi(id, value, reason) {
const node = document.getElementById(id);
const detail = document.getElementById(`${id}-reason`);
if (node) node.textContent = value;
if (detail) detail.textContent = reason;
}
function renderWorkflowHeader(workflow) {
const definition = workflow.definition || {};
const run = workflow.run || {};
document.getElementById('workflow-title').textContent = definition.name || 'Workflow без названия';
const state = document.getElementById('workflow-run-state');
state.textContent = workflowRunLabel(run.status);
state.className = `workflow-run-state ${wfEscape(run.status || 'idle')}`;
document.getElementById('workflow-iteration').textContent = run.iteration || 'Н/Д';
if (!workflowUi.dirty) document.getElementById('workflow-max-iterations').value = definition.max_iterations || 1;
document.getElementById('workflow-run-error').textContent = run.error || '';
document.getElementById('btn-workflow-start').disabled = run.status === 'running' || run.status === 'stopping';
document.getElementById('btn-workflow-stop').disabled = run.status !== 'running';
}
function workflowRunLabel(status) {
return ({ idle: 'Нет активного запуска', running: '● Запущен', stopping: 'Останавливается', completed: 'Завершён', failed: 'Ошибка', stopped: 'Остановлен', interrupted: 'Прерван перезапуском' })[status] || `Н/Д: неизвестный статус ${status || 'не указан'}`;
}
function renderWorkflowNodes(workflow) {
const layer = document.getElementById('workflow-node-layer');
const canvas = document.getElementById('workflow-canvas');
const agents = workflow.agents || [];
canvas.classList.toggle('workflow-mode-edit', workflowUi.mode === 'edit');
document.getElementById('workflow-empty').classList.toggle('hidden', agents.length > 0);
layer.innerHTML = agents.map((agent) => {
const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 };
const cfg = agent.execution_config || {};
const assignment = cfg.unavailable_reason
? `Н/Д: ${cfg.unavailable_reason}`
: [cfg.provider, cfg.model, cfg.account].filter(Boolean).join(' · ');
return `<article class="workflow-node ${wfEscape(agent.runtime_state)} ${workflowUi.selectedAgentId === agent.id ? 'selected' : ''}" data-agent-id="${wfEscape(agent.id)}" style="left:${Number(pos.x) || 0}px;top:${Number(pos.y) || 0}px">
<button class="workflow-port in" aria-label="Вход"></button><button class="workflow-port out" aria-label="Создать связь"></button>
<h3>${wfEscape(agent.name)}</h3><p>${wfEscape(agent.role)}</p><p>${wfEscape(agent.agent_file)}</p><p title="${wfEscape(assignment)}">${wfEscape(assignment)}</p>
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
</article>`;
}).join('');
layer.style.transform = `scale(${workflowUi.scale})`;
layer.querySelectorAll('.workflow-node').forEach((node) => {
node.addEventListener('click', () => selectWorkflowAgent(node.dataset.agentId));
node.addEventListener('mousedown', beginNodeDrag);
node.querySelector('.workflow-port.out')?.addEventListener('mousedown', beginConnection);
node.querySelector('.workflow-port.in')?.addEventListener('mouseup', finishConnection);
});
renderWorkflowMinimap(agents);
}
function runtimeStateLabel(state) {
return ({ waiting: 'Ожидает', working: 'Работает', reviewing: 'Проверяет', error: 'Ошибка', completed: 'Завершено', not_implemented: 'Исполнение не реализовано' })[state] || `Н/Д: ${state || 'статус не получен'}`;
}
function setWorkflowMode(mode) {
if (mode === 'edit' && currentSnapshot?.workflow?.run?.status === 'running') {
showToast('EDIT недоступен во время LIVE-выполнения', 'warning');
return;
}
workflowUi.mode = mode;
document.querySelectorAll('.workflow-mode-btn').forEach((button) => button.classList.toggle('active', button.dataset.mode === mode));
renderWorkflowOverview(currentSnapshot);
}
function selectWorkflowAgent(agentId) {
if (workflowUi.drag?.moved) return;
workflowUi.selectedAgentId = agentId;
renderWorkflowOverview(currentSnapshot);
}
function beginNodeDrag(event) {
if (workflowUi.mode !== 'edit' || event.target.classList.contains('workflow-port')) return;
const node = event.currentTarget;
const position = workflowUi.draftPositions[node.dataset.agentId] || { x: node.offsetLeft, y: node.offsetTop };
workflowUi.drag = { id: node.dataset.agentId, startX: event.clientX, startY: event.clientY, original: { ...position }, moved: false };
event.preventDefault();
}
function workflowPointerMove(event) {
if (workflowUi.drag) {
const drag = workflowUi.drag;
const dx = (event.clientX - drag.startX) / workflowUi.scale;
const dy = (event.clientY - drag.startY) / workflowUi.scale;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
workflowUi.draftPositions[drag.id] = { x: Math.max(0, drag.original.x + dx), y: Math.max(0, drag.original.y + dy) };
const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(drag.id)}"]`);
if (node) {
node.style.left = `${workflowUi.draftPositions[drag.id].x}px`;
node.style.top = `${workflowUi.draftPositions[drag.id].y}px`;
}
drawWorkflowEdges();
}
}
function workflowPointerUp(event) {
if (workflowUi.drag) {
if (workflowUi.drag.moved) markWorkflowDirty();
workflowUi.drag = null;
}
if (workflowUi.connectingFrom && event.target === document.getElementById('workflow-canvas')) {
workflowUi.connectingFrom = null;
showToast('Создание связи отменено', 'info');
}
}
function workflowPointerCancel() {
if (workflowUi.drag) {
workflowUi.draftPositions[workflowUi.drag.id] = workflowUi.drag.original;
workflowUi.drag = null;
renderWorkflowOverview(currentSnapshot);
}
workflowUi.connectingFrom = null;
}
function beginConnection(event) {
if (workflowUi.mode !== 'edit') return;
workflowUi.connectingFrom = event.currentTarget.closest('.workflow-node').dataset.agentId;
event.stopPropagation();
event.preventDefault();
showToast('Выберите входной порт целевого агента', 'info');
}
function finishConnection(event) {
const target = event.currentTarget.closest('.workflow-node').dataset.agentId;
const source = workflowUi.connectingFrom;
workflowUi.connectingFrom = null;
event.stopPropagation();
if (!source || source === target) return;
openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true);
}
function drawWorkflowEdges() {
const canvas = document.getElementById('workflow-canvas');
const svg = document.getElementById('workflow-edges');
const layer = document.getElementById('workflow-edge-layer');
if (!canvas || !svg || !layer) return;
svg.setAttribute('viewBox', `0 0 ${canvas.clientWidth} ${canvas.clientHeight}`);
const parts = [];
workflowUi.draftEdges.forEach((edge) => {
const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`);
const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`);
if (!source || !target) return;
const x1 = (source.offsetLeft + source.offsetWidth) * workflowUi.scale;
const y1 = (source.offsetTop + source.offsetHeight / 2) * workflowUi.scale;
const x2 = target.offsetLeft * workflowUi.scale;
const y2 = (target.offsetTop + target.offsetHeight / 2) * workflowUi.scale;
const bend = Math.max(45, Math.abs(x2 - x1) * .42);
const path = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
const klass = String(edge.condition || '').toLowerCase();
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2 - 5;
parts.push(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path><text class="workflow-edge-label" x="${labelX}" y="${labelY}">${wfEscape(edge.label || edge.condition)}</text>`);
});
layer.innerHTML = parts.join('');
layer.querySelectorAll('.workflow-edge-hit').forEach((path) => path.addEventListener('click', () => {
const edge = workflowUi.draftEdges.find((item) => item.id === path.dataset.edgeId);
if (workflowUi.mode === 'edit' && edge) openEdgeDialog(edge, false);
}));
}
function renderWorkflowMinimap(agents) {
const minimap = document.getElementById('workflow-minimap');
if (!minimap) return;
const maxX = Math.max(900, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 200));
const maxY = Math.max(500, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 100));
minimap.innerHTML = agents.map((agent) => {
const pos = workflowUi.draftPositions[agent.id] || { x: 0, y: 0 };
return `<i style="left:${pos.x / maxX * 92}px;top:${pos.y / maxY * 55}px"></i>`;
}).join('');
}
function setWorkflowScale(value) {
workflowUi.scale = Math.max(.5, Math.min(1.6, Math.round(value * 10) / 10));
document.getElementById('workflow-zoom-value').textContent = `${Math.round(workflowUi.scale * 100)}%`;
renderWorkflowOverview(currentSnapshot);
}
function fitWorkflowGraph() {
const agents = currentSnapshot?.workflow?.agents || [];
const maxX = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 210), 600);
const maxY = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 110), 400);
const canvas = document.getElementById('workflow-canvas');
setWorkflowScale(Math.min(canvas.clientWidth / maxX, canvas.clientHeight / maxY, 1));
}
function markWorkflowDirty() {
workflowUi.dirty = true;
const state = document.getElementById('workflow-save-state');
state.textContent = 'Есть несохранённые изменения';
state.classList.add('dirty');
}
async function saveWorkflowDraft() {
const definition = currentSnapshot?.workflow?.definition || {};
const result = await executeAction('save_workflow', {
id: definition.id,
name: definition.name,
start_agent_id: definition.start_agent_id,
escalation_agent_id: definition.escalation_agent_id,
max_iterations: Number(document.getElementById('workflow-max-iterations').value),
edges: workflowUi.draftEdges,
agents: Object.entries(workflowUi.draftPositions).map(([id, position]) => ({ id, position })),
});
if (result.ok) {
workflowUi.dirty = false;
const state = document.getElementById('workflow-save-state');
state.textContent = 'Сохранено';
state.classList.remove('dirty');
}
}
function renderWorkflowInspector(snapshot, workflow) {
const box = document.getElementById('workflow-inspector');
const agent = (workflow.agents || []).find((item) => item.id === workflowUi.selectedAgentId);
if (!agent) {
box.innerHTML = '<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>';
return;
}
const cfg = agent.execution_config || {};
const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']];
box.innerHTML = `<h2>INSPECTOR: ${wfEscape(agent.name)}</h2><div class="inspector-tabs">${tabs.map(([id, label]) => `<button data-tab="${id}" class="${workflowUi.selectedTab === id ? 'active' : ''}">${label}</button>`).join('')}</div><div id="inspector-tab-content"></div>`;
box.querySelectorAll('.inspector-tabs button').forEach((button) => button.addEventListener('click', () => {
workflowUi.selectedTab = button.dataset.tab;
renderWorkflowInspector(snapshot, workflow);
}));
const content = document.getElementById('inspector-tab-content');
if (workflowUi.selectedTab === 'main') {
content.innerHTML = `<label class="inspector-field">Название<input id="agent-edit-name" value="${wfEscape(agent.name)}"></label><label class="inspector-field">Роль<div class="inspector-value">${wfEscape(agent.role)}</div></label><label class="inspector-field">Описание<textarea id="agent-edit-description">${wfEscape(agent.description)}</textarea></label><label class="inspector-field">Runtime status<div class="inspector-value">${wfEscape(runtimeStateLabel(agent.runtime_state))}</div></label><label class="inspector-field">Текущая задача<div class="inspector-value">${wfEscape(workflow.run?.current_agent_id === agent.id ? workflow.run.current_task : 'Н/Д: агент сейчас не выполняется')}</div></label><div class="inspector-actions"><button class="btn btn-primary btn-sm" id="agent-save-main">Сохранить</button><button class="btn btn-secondary btn-sm" id="agent-delete">Удалить</button></div>`;
document.getElementById('agent-save-main').onclick = () => executeAction('update_agent', { agent_id: agent.id, name: document.getElementById('agent-edit-name').value, description: document.getElementById('agent-edit-description').value });
document.getElementById('agent-delete').onclick = () => deleteWorkflowAgent(agent);
} else if (workflowUi.selectedTab === 'model') {
renderAgentModelTab(content, snapshot, agent);
} else if (workflowUi.selectedTab === 'instructions') {
content.innerHTML = `<div class="agent-file-card"><strong>Agent File</strong><code>${wfEscape(agent.agent_file)}</code><span>${agent.agent_file_exists ? 'Файл существует' : 'Н/Д: файл отсутствует'}</span><div class="inspector-actions"><button class="btn btn-secondary btn-sm" id="agent-file-open">Открыть в редакторе</button></div></div>`;
document.getElementById('agent-file-open').onclick = () => openAgentFileEditor(agent);
} else if (workflowUi.selectedTab === 'tools') {
content.innerHTML = `<label class="inspector-field">Инструменты, через запятую<input id="agent-tools" value="${wfEscape((agent.tools || []).join(', '))}"></label><button class="btn btn-primary btn-sm" id="agent-tools-save">Сохранить</button>`;
document.getElementById('agent-tools-save').onclick = () => executeAction('update_agent', { agent_id: agent.id, tools: document.getElementById('agent-tools').value.split(',').map((v) => v.trim()).filter(Boolean) });
} else if (workflowUi.selectedTab === 'memory') {
content.innerHTML = `<div class="inspector-value">${Object.keys(agent.memory_configuration || {}).length ? `<pre>${wfEscape(JSON.stringify(agent.memory_configuration, null, 2))}</pre>` : 'Н/Д: конфигурация памяти не задана'}</div>`;
} else {
const history = (workflow.events || []).filter((event) => event.agent_id === agent.id);
content.innerHTML = history.length ? history.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}</span><em>${event.duration_seconds == null ? '' : `${event.duration_seconds} с`}</em></div>`).join('') : '<div class="inspector-value">Н/Д: у агента ещё нет запусков</div>';
}
}
function renderAgentModelTab(content, snapshot, agent) {
const profiles = Object.values(snapshot.all_profiles || {}).filter((profile) => isConnectedProfile(profile));
const cfg = agent.execution_config || {};
const providers = [...new Set(profiles.map((profile) => profile.provider))];
content.innerHTML = `<label class="inspector-field">Провайдер<select id="agent-provider"><option value="">Не назначен</option>${providers.map((provider) => `<option value="${wfEscape(provider)}" ${provider === cfg.provider ? 'selected' : ''}>${wfEscape(provider)}</option>`).join('')}</select></label><label class="inspector-field">Аккаунт<select id="agent-account"></select></label><label class="inspector-field">Модель<select id="agent-model"></select></label><label class="inspector-field">Температура<input id="agent-temperature" type="number" step="0.1" min="0" max="2" value="${cfg.temperature ?? ''}" placeholder="Н/Д: не задана"></label><label class="inspector-field">Макс. токенов<input id="agent-max-tokens" type="number" min="1" value="${cfg.max_tokens ?? ''}" placeholder="Н/Д: не задано"></label><label class="inspector-field">Таймаут, с<input id="agent-timeout" type="number" min="1" value="${cfg.timeout || ''}"></label><button class="btn btn-primary btn-sm" id="agent-model-save">Изменить конфигурацию</button>`;
const providerSelect = document.getElementById('agent-provider');
const accountSelect = document.getElementById('agent-account');
const modelSelect = document.getElementById('agent-model');
const refreshAccounts = () => {
const matches = profiles.filter((profile) => profile.provider === providerSelect.value);
accountSelect.innerHTML = matches.length ? matches.map((profile) => `<option value="${wfEscape(profile.profile_id)}" ${profile.profile_id === cfg.account ? 'selected' : ''}>${wfEscape(profile.account_identity || profile.display_name || profile.profile_id)}</option>`).join('') : '<option value="">Н/Д: нет подключённых аккаунтов</option>';
refreshModels();
};
const refreshModels = () => {
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean);
modelSelect.innerHTML = models.length ? models.map((model) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: модели не обнаружены</option>';
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;
refreshAccounts();
document.getElementById('agent-model-save').onclick = () => executeAction('update_agent', {
agent_id: agent.id, provider: providerSelect.value, profile_id: accountSelect.value, model: modelSelect.value,
temperature: nullableNumber('agent-temperature'), max_tokens: nullableNumber('agent-max-tokens'), timeout: nullableNumber('agent-timeout'),
});
}
function nullableNumber(id) {
const value = document.getElementById(id).value.trim();
return value === '' ? null : Number(value);
}
async function deleteWorkflowAgent(agent) {
let result = await executeAction('delete_agent', { agent_id: agent.id });
if (result.ok && result.data?.confirmation_required) {
const refs = result.data.consequences?.workflow_edges || [];
if (!confirm(`Агент участвует в маршруте и/или графе. Будут удалены связи: ${refs.length ? refs.join(', ') : 'нет'}. Продолжить?`)) return;
result = await executeAction('delete_agent', { agent_id: agent.id, force: true });
}
if (result.ok && result.data?.deleted) workflowUi.selectedAgentId = null;
}
function openAgentCreateDialog() {
const profiles = Object.values(currentSnapshot?.all_profiles || {}).filter((profile) => isConnectedProfile(profile));
openWorkflowDialog('Добавить агента', `<label>Название<input id="new-agent-name" required></label><label>Роль (произвольный идентификатор)<input id="new-agent-role" placeholder="security-reviewer"></label><label>Описание<textarea id="new-agent-description"></textarea></label><label>Аккаунт<select id="new-agent-account"><option value="">Не назначать</option>${profiles.map((profile) => `<option value="${wfEscape(profile.profile_id)}">${wfEscape(profile.provider)} · ${wfEscape(profile.account_identity || profile.profile_id)}</option>`).join('')}</select></label><label>Agent File<input id="new-agent-file" placeholder="agents/имя.md"></label>`, async (close) => {
const account = document.getElementById('new-agent-account').value;
const profile = profiles.find((item) => item.profile_id === account);
const result = await executeAction('create_agent', {
name: document.getElementById('new-agent-name').value,
role: document.getElementById('new-agent-role').value,
description: document.getElementById('new-agent-description').value,
account,
model: profile?.preferred_models?.[0] || null,
agent_file: document.getElementById('new-agent-file').value,
});
if (result.ok) close();
});
}
async function openAgentFileEditor(agent) {
const result = await executeAction('read_agent_file', { agent_id: agent.id });
if (!result.ok) return;
const data = result.data || {};
if (!data.exists) {
showToast(`Н/Д: ${data.reason}`, 'warning');
return;
}
openWorkflowDialog(`Agent File — ${agent.name}`, `<label>Путь<input value="${wfEscape(data.path)}" readonly></label><label>Markdown<textarea id="agent-file-content" class="agent-file-editor">${wfEscape(data.content)}</textarea></label><p id="agent-file-unsaved">Нет изменений</p>`, async (close) => {
const saved = await executeAction('save_agent_file', { agent_id: agent.id, content: document.getElementById('agent-file-content').value });
if (saved.ok) close();
}, 'Сохранить');
const editor = document.getElementById('agent-file-content');
editor.addEventListener('input', () => { document.getElementById('agent-file-unsaved').textContent = 'Есть несохранённые изменения'; });
}
function openEdgeDialog(edge, isNew) {
openWorkflowDialog(isNew ? 'Новое ребро' : 'Редактор ребра', `<label>От<input value="${wfEscape(edge.source)}" readonly></label><label>К<input value="${wfEscape(edge.target)}" readonly></label><label>Условие<select id="edge-condition">${['SUCCESS', 'REVIEW_PASSED', 'REVIEW_FAILED', 'NEXT', 'ERROR', 'ALWAYS'].map((condition) => `<option ${condition === edge.condition ? 'selected' : ''}>${condition}</option>`).join('')}</select></label><label>Подпись<input id="edge-label" value="${wfEscape(edge.label || '')}"></label>${isNew ? '' : '<button class="btn btn-secondary btn-sm" id="edge-delete">Удалить ребро</button>'}`, (close) => {
const updated = { ...edge, condition: document.getElementById('edge-condition').value, label: document.getElementById('edge-label').value };
const index = workflowUi.draftEdges.findIndex((item) => item.id === edge.id);
if (index >= 0) workflowUi.draftEdges[index] = updated; else workflowUi.draftEdges.push(updated);
markWorkflowDirty(); close(); drawWorkflowEdges();
});
if (!isNew) document.getElementById('edge-delete').onclick = () => {
workflowUi.draftEdges = workflowUi.draftEdges.filter((item) => item.id !== edge.id);
markWorkflowDirty(); document.querySelector('.workflow-dialog-backdrop')?.remove(); drawWorkflowEdges();
};
}
function openWorkflowDialog(title, body, onSave, saveLabel = 'Применить') {
document.querySelector('.workflow-dialog-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'workflow-dialog-backdrop';
backdrop.innerHTML = `<div class="workflow-dialog"><h2>${wfEscape(title)}</h2>${body}<div class="workflow-dialog-actions"><button class="btn btn-secondary" data-dialog-cancel>Отмена</button><button class="btn btn-primary" data-dialog-save>${wfEscape(saveLabel)}</button></div></div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector('[data-dialog-cancel]').onclick = close;
backdrop.querySelector('[data-dialog-save]').onclick = () => onSave(close);
backdrop.addEventListener('click', (event) => { if (event.target === backdrop) close(); });
}
async function startWorkflowRun() {
if (workflowUi.dirty) {
showToast('Сначала сохраните изменения графа', 'warning');
return;
}
const task = document.getElementById('workflow-task').value.trim();
await executeAction('start_workflow', { task });
setWorkflowMode('live');
}
function renderWorkflowEvents(events) {
const box = document.getElementById('workflow-events-list');
if (!events.length) {
box.innerHTML = '<p>Н/Д: workflow ещё не создавал событий</p>';
return;
}
box.innerHTML = events.slice(-20).reverse().map((event) => `<div class="workflow-event ${wfEscape(event.level)}"><time>${wfEscape(formatWorkflowTime(event.timestamp))}</time><span>${wfEscape(event.message)}${event.error ? `${wfEscape(event.error)}` : ''}</span><em>${wfEscape(event.type)}</em></div>`).join('');
}
function formatWorkflowTime(value) {
if (!value) return 'Н/Д';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU');
}

View file

@ -0,0 +1,785 @@
"""Persistent agents, workflow graph and live execution for Hermes Hub.
The router role registry remains the source of truth for logical agents and
Provider -> Account -> Model assignment. This module adds the pieces that do
not fit the routing schema: Agent Files, editor layout, workflow transitions,
execution checkpoints and a bounded event journal.
"""
from __future__ import annotations
import json
import re
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional
from antigravity_provider import paths
from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config
AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"}
EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"}
def _utc_timestamp() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _slug(value: str) -> str:
result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
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]:
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
root = paths.get_agent_files_dir().resolve()
candidate_name = Path(value or f"{agent_id}.md").name
if not candidate_name.lower().endswith(".md"):
candidate_name += ".md"
target = (root / candidate_name).resolve()
if target.parent != root:
raise ValueError("Agent File должен находиться в каталоге agents")
return target, f"agents/{candidate_name}"
@dataclass
class AgentDefinition:
id: str
name: str
role: str
description: str = ""
agent_file: str = ""
tools: list[str] = field(default_factory=list)
memory_configuration: dict[str, Any] = field(default_factory=dict)
execution_policy: dict[str, Any] = field(default_factory=dict)
timeout: int = 180
temperature: Optional[float] = None
max_tokens: Optional[int] = None
position: dict[str, float] = field(default_factory=lambda: {"x": 80.0, "y": 80.0})
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkflowEdge:
id: str
source: str
target: str
condition: str = "SUCCESS"
label: str = ""
@dataclass
class WorkflowDefinition:
id: str = "main"
name: str = "Основной workflow"
edges: list[WorkflowEdge] = field(default_factory=list)
max_iterations: int = 5
escalation_agent_id: Optional[str] = None
start_agent_id: Optional[str] = None
@dataclass
class WorkflowEvent:
timestamp: str
type: str
message: str
level: str = "info"
run_id: Optional[str] = None
agent_id: Optional[str] = None
iteration: Optional[int] = None
provider: Optional[str] = None
account: Optional[str] = None
model: Optional[str] = None
duration_seconds: Optional[float] = None
error: Optional[str] = None
class WorkflowService:
_instance: Optional["WorkflowService"] = None
_instance_lock = threading.Lock()
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.run_state_path = run_state_path or paths.get_workflow_run_state_path()
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self.agents: dict[str, AgentDefinition] = {}
self.workflow = WorkflowDefinition()
self.events: list[WorkflowEvent] = []
self.run: dict[str, Any] = self._idle_run()
self._completed_steps: list[dict[str, Any]] = []
self._load()
self._migrate_router_roles()
@classmethod
def get(cls) -> "WorkflowService":
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
@staticmethod
def _idle_run() -> dict[str, Any]:
return {
"id": None,
"status": "idle",
"current_agent_id": None,
"current_task": None,
"iteration": 0,
"started_at": None,
"finished_at": None,
"elapsed_seconds": None,
"last_result": None,
"error": None,
"agent_states": {},
}
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():
return
try:
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
self.agents = {
item["id"]: AgentDefinition(**item)
for item in raw.get("agents", [])
if isinstance(item, dict) and item.get("id")
}
wf = raw.get("workflow") or {}
edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)]
self.workflow = WorkflowDefinition(edges=edges, **wf)
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
self.run = raw.get("run") or self._idle_run()
if self.run.get("status") in {"running", "stopping"}:
self.run["status"] = "interrupted"
self.run["error"] = "Выполнение прервано перезапуском Hermes Hub; checkpoint сохранён"
self._event("WORKFLOW_INTERRUPTED", self.run["error"], level="warning")
except (OSError, ValueError, TypeError):
self.agents = {}
self.workflow = WorkflowDefinition()
self.events = []
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:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema_version": 1,
"agents": [asdict(agent) for agent in self.agents.values()],
"workflow": asdict(self.workflow),
"events": [asdict(event) for event in self.events[-200:]],
"run": self.run,
}
temp = self.state_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(self.state_path)
def _migrate_router_roles(self) -> None:
config = load_router_config()
changed = False
for index, (role_id, policy) in enumerate(config.roles.items()):
if role_id in self.agents:
continue
target, relative = _safe_agent_file("", role_id)
name = role_id.replace("-", " ").title()
description = ""
try:
from antigravity_provider.router.role_registry import get_role_definition
definition = get_role_definition(role_id)
name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name
description = getattr(definition, "description", "")
except (ImportError, AttributeError, TypeError):
pass
agent = AgentDefinition(
id=role_id,
name=name,
role=role_id,
description=description,
agent_file=relative,
position={"x": 70.0 + (index % 2) * 280.0, "y": 45.0 + (index // 2) * 125.0},
)
self.agents[role_id] = agent
self._ensure_file(target, agent)
changed = True
if changed:
self._save()
@staticmethod
def _ensure_file(target: Path, agent: AgentDefinition) -> None:
if not target.exists():
body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n"
target.write_text(body, encoding="utf-8")
def _execution_config(self, agent: AgentDefinition) -> dict[str, Any]:
config = load_router_config()
policy = config.roles.get(agent.role)
profile_id = policy.preferred_chain[0] if policy and policy.preferred_chain else None
profile = config.profiles.get(profile_id) if profile_id else None
model = policy.default_model if policy else None
if not model and profile and profile.preferred_models:
model = profile.preferred_models[0]
return {
"provider": profile.provider if profile else None,
"account": profile_id,
"model": model,
"timeout": agent.timeout,
"temperature": agent.temperature,
"max_tokens": agent.max_tokens,
"unavailable_reason": None if profile else "Для роли не назначен доступный аккаунт",
}
def snapshot(self) -> dict[str, Any]:
with self._lock:
agents = []
for agent in self.agents.values():
item = asdict(agent)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
item["agent_file"] = relative
item["agent_file_exists"] = target.is_file()
item["execution_config"] = self._execution_config(agent)
item["runtime_state"] = (self.run.get("agent_states") or {}).get(agent.id, "waiting")
agents.append(item)
return {
"agents": agents,
"definition": asdict(self.workflow),
"run": dict(self.run),
"events": [asdict(event) for event in self.events[-60:]],
"is_loading": False,
}
def read_agent_file(self, agent_id: str) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
if not target.is_file():
return {"path": relative, "exists": False, "content": None, "reason": "Файл не найден на диске"}
return {"path": relative, "exists": True, "content": target.read_text(encoding="utf-8")}
def save_agent_file(self, agent_id: str, content: str) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
target, relative = _safe_agent_file(agent.agent_file, agent.id)
temporary = target.with_suffix(".md.tmp")
temporary.write_text(str(content), encoding="utf-8")
temporary.replace(target)
self._event("AGENT_FILE_SAVED", f"Сохранён Agent File {relative}", agent_id=agent_id)
self._save()
return {"path": relative, "exists": True}
def create_agent(self, data: dict[str, Any]) -> AgentDefinition:
name = str(data.get("name") or "").strip()
role = _slug(str(data.get("role") or name))
agent_id = _slug(str(data.get("id") or role))
if not name:
raise ValueError("Укажите название агента")
with self._lock:
if agent_id in self.agents:
raise ValueError("Агент с таким идентификатором уже существует")
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
config = load_router_config()
if profile_id and profile_id not in config.profiles:
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
config.roles[role] = RolePolicy(
role_name=role,
preferred_chain=[profile_id] if profile_id else [],
fallback_capabilities=list(data.get("fallback_capabilities") or [role]),
default_model=data.get("model") or None,
)
if not save_router_config(config):
raise OSError("Не удалось сохранить назначение агента")
target, relative = _safe_agent_file(str(data.get("agent_file") or ""), agent_id)
agent = AgentDefinition(
id=agent_id,
name=name,
role=role,
description=str(data.get("description") or ""),
agent_file=relative,
tools=[str(item) for item in data.get("tools", [])],
memory_configuration=dict(data.get("memory_configuration") or {}),
execution_policy=dict(data.get("execution_policy") or {}),
timeout=max(1, int(data.get("timeout") or 180)),
temperature=float(data["temperature"]) if data.get("temperature") is not None else None,
max_tokens=int(data["max_tokens"]) if data.get("max_tokens") is not None else None,
position=dict(data.get("position") or {"x": 80.0, "y": 80.0}),
)
self.agents[agent_id] = agent
copy_from = data.get("copy_from")
if copy_from:
source = self.read_agent_file(str(copy_from))
target.write_text(source.get("content") or "", encoding="utf-8")
else:
self._ensure_file(target, agent)
self._event("AGENT_CREATED", f"Создан агент «{name}»", agent_id=agent_id)
self._save()
return agent
def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition:
with self._lock:
agent = self._require_agent(agent_id)
config = load_router_config()
policy = config.roles.get(agent.role)
if not policy:
policy = RolePolicy(role_name=agent.role)
config.roles[agent.role] = policy
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
if profile_id:
profile = config.profiles.get(profile_id)
if not profile:
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
requested_provider = str(data.get("provider") or "").strip()
if requested_provider and profile.provider != requested_provider:
raise ValueError("Аккаунт не принадлежит выбранному провайдеру")
policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id]
if "model" in data:
model = str(data.get("model") or "").strip() or None
if model and profile_id:
profile = config.profiles[profile_id]
if model not in profile.preferred_models:
raise ValueError("Модель не доступна выбранному аккаунту")
policy.default_model = model
if not save_router_config(config):
raise OSError("Не удалось сохранить назначение агента")
for attr in ("name", "description"):
if attr in data:
setattr(agent, attr, str(data[attr]).strip())
for attr in ("tools", "memory_configuration", "execution_policy", "position"):
if attr in data:
setattr(agent, attr, type(getattr(agent, attr))(data[attr]))
for attr in ("timeout", "max_tokens"):
if attr in data and data[attr] is not None:
setattr(agent, attr, int(data[attr]))
if "temperature" in data:
agent.temperature = float(data["temperature"]) if data["temperature"] is not None else None
self._event("AGENT_UPDATED", f"Обновлён агент «{agent.name}»", agent_id=agent_id)
self._save()
return agent
def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]:
with self._lock:
agent = self._require_agent(agent_id)
edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id]
route_used = bool(load_router_config().roles.get(agent.role))
consequences = {"workflow_edges": edge_ids, "routing_role": agent.role if route_used else None}
if (edge_ids or route_used) and not force:
return {"deleted": False, "confirmation_required": True, "consequences": consequences}
config = load_router_config()
config.roles.pop(agent.role, None)
if not save_router_config(config):
raise OSError("Не удалось удалить роль из маршрутизатора")
self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids]
self.agents.pop(agent_id)
self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning")
self._save()
return {"deleted": True, "consequences": consequences}
def save_workflow(self, data: dict[str, Any]) -> WorkflowDefinition:
with self._lock:
if self.run.get("status") in {"running", "stopping"}:
raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения")
edges: list[WorkflowEdge] = []
seen: set[str] = set()
for raw in data.get("edges", []):
source, target = str(raw.get("source") or ""), str(raw.get("target") or "")
condition = str(raw.get("condition") or "SUCCESS").upper()
if source not in self.agents or target not in self.agents:
raise ValueError("Ребро ссылается на отсутствующего агента")
if condition not in EDGE_CONDITIONS:
raise ValueError(f"Неизвестное условие перехода: {condition}")
edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}")
if edge_id in seen:
raise ValueError("Идентификаторы рёбер должны быть уникальны")
seen.add(edge_id)
edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or "")))
max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations)
if not 1 <= max_iterations <= 100:
raise ValueError("Предел итераций должен быть от 1 до 100")
for raw_agent in data.get("agents", []):
agent = self.agents.get(str(raw_agent.get("id") or ""))
pos = raw_agent.get("position")
if agent and isinstance(pos, dict):
agent.position = {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))}
self.workflow = WorkflowDefinition(
id=str(data.get("id") or self.workflow.id),
name=str(data.get("name") or self.workflow.name),
edges=edges,
max_iterations=max_iterations,
escalation_agent_id=data.get("escalation_agent_id") or None,
start_agent_id=data.get("start_agent_id") or None,
)
self._event("WORKFLOW_SAVED", f"Сохранён workflow «{self.workflow.name}»")
self._save()
return self.workflow
def start(self, task: str) -> dict[str, Any]:
task = str(task or "").strip()
if not task:
raise ValueError("Для запуска укажите реальную задачу")
with self._lock:
if self._thread and self._thread.is_alive():
raise ValueError("Workflow уже выполняется")
start_id = self.workflow.start_agent_id or (next(iter(self.agents), None))
if not start_id or start_id not in self.agents:
raise ValueError("В workflow нет стартового агента")
self._stop.clear()
self._completed_steps = []
self.run = self._idle_run()
self.run.update({
"id": uuid.uuid4().hex,
"status": "running",
"current_agent_id": start_id,
"current_task": task,
"iteration": 1,
"started_at": _utc_timestamp(),
})
self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1)
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.start()
return dict(self.run)
def stop(self) -> dict[str, Any]:
with self._lock:
if self.run.get("status") != "running":
raise ValueError("Нет выполняющегося workflow")
self.run["status"] = "stopping"
self._stop.set()
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
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)
def _execute(self) -> None:
from antigravity_provider.router.router_engine import get_router_engine
started = time.monotonic()
context = str(self.run.get("current_task") or "")
current = str(self.run.get("current_agent_id") or "")
visited: dict[str, int] = {}
try:
engine = get_router_engine()
engine.reload_config()
while current and not self._stop.is_set():
with self._lock:
agent = self._require_agent(current)
visited[current] = visited.get(current, 0) + 1
iteration = max(visited.values())
self.run.update({"current_agent_id": current, "iteration": iteration})
step_idx = len(self._completed_steps)
if iteration > self.workflow.max_iterations:
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
self.run.update({"status": "failed", "error": message})
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
file_data = self.read_agent_file(current)
if not file_data["exists"]:
raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}")
self.run.setdefault("agent_states", {})[current] = (
"reviewing" if "review" in agent.role.lower() else "working"
)
self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration)
self._save()
self._save_run_state("RUNNING", step_index=step_idx, current_agent=current, iteration=iteration)
request = {
"model": self._execution_config(agent).get("model"),
"messages": [
{"role": "system", "content": file_data["content"]},
{"role": "user", "content": context},
],
"timeout": agent.timeout,
"metadata": {"role": agent.role, "workflow_run_id": self.run["id"]},
}
if agent.temperature is not None:
request["temperature"] = agent.temperature
if agent.max_tokens is not None:
request["max_tokens"] = agent.max_tokens
step_started = time.monotonic()
response = engine.route_request(request, role=agent.role, session_id=self.run["id"])
duration = round(time.monotonic() - step_started, 3)
text = self._response_text(response)
status = self._result_status(response, text)
metadata = response.get("router_metadata", {}) if isinstance(response, dict) else {}
with self._lock:
self.run["last_result"] = {"status": status, "content": text, "router_metadata": metadata}
self.run.setdefault("agent_states", {})[current] = (
"error" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
)
self._event(
"AGENT_COMPLETED",
f"{agent.name}: {status}",
level="success" if status not in {"ERROR", "REVIEW_FAILED"} else "warning",
agent_id=current,
iteration=iteration,
provider=metadata.get("provider"),
account=metadata.get("profile_id"),
model=metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
duration_seconds=duration,
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(
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
None,
)
if not edge and status not in {"ERROR", "REVIEW_FAILED"}:
edge = next(
(item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}),
None,
)
if not edge:
self.run["status"] = "failed" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
if status == "ERROR":
self.run["error"] = text or "Провайдер вернул ERROR без текста"
self._event(
"WORKFLOW_FAILED" if self.run["status"] == "failed" else "WORKFLOW_COMPLETED",
f"Workflow завершён со статусом {status}",
level="error" if self.run["status"] == "failed" else "success",
agent_id=current,
iteration=iteration,
error=self.run.get("error") if self.run["status"] == "failed" else None,
)
if status == "ERROR":
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
"workflow",
f"Ошибка агента «{agent.name}»",
details=self.run["error"],
level="error",
)
except Exception:
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
self._event(
"WORKFLOW_TRANSITION",
f"Переход {current}{edge.target}: {edge.condition}",
agent_id=current,
iteration=iteration,
)
context = json.dumps({
"original_task": self.run["current_task"],
"previous_agent": current,
"structured_result": {"status": status, "content": text},
}, ensure_ascii=False)
current = edge.target
self._save()
self._save_run_state(
"RUNNING",
step_index=step_idx + 1,
current_agent=current,
iteration=iteration,
completed_step=step_summary,
)
with self._lock:
if self._stop.is_set():
self.run.update({"status": "stopped", "error": "Остановлено пользователем"})
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:
with self._lock:
self.run.update({"status": "failed", "error": str(exc)})
self._event("PROVIDER_ERROR", "Ошибка выполнения workflow", level="error", agent_id=current, error=str(exc))
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error")
except Exception:
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:
with self._lock:
self.run["finished_at"] = _utc_timestamp()
self.run["elapsed_seconds"] = round(time.monotonic() - started, 3)
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
self._save()
@staticmethod
def _response_text(response: Any) -> str:
if not isinstance(response, dict):
return str(response)
choices = response.get("choices")
if isinstance(choices, list) and choices:
message = choices[0].get("message", {})
return str(message.get("content") or choices[0].get("text") or "")
return str(response.get("content") or response.get("text") or response.get("output") or "")
@staticmethod
def _result_status(response: Any, text: str) -> str:
if isinstance(response, dict):
explicit = response.get("status") or response.get("structured_status")
if explicit and str(explicit).upper() in EDGE_CONDITIONS:
return str(explicit).upper()
for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"):
if re.search(rf"\b{status}\b", text.upper()):
return status
return "SUCCESS"
def _event(self, event_type: str, message: str, **kwargs: Any) -> None:
self.events.append(WorkflowEvent(_utc_timestamp(), event_type, message, **kwargs))
self.events = self.events[-200:]
def _require_agent(self, agent_id: str) -> AgentDefinition:
agent = self.agents.get(agent_id)
if not agent:
raise ValueError("Агент не найден")
return agent
def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]:
"""Execute a workflow action through the shared ActionExecutor layer."""
service = WorkflowService.get()
if action == "create_agent":
result = asdict(service.create_agent(data))
elif action == "update_agent":
result = asdict(service.update_agent(str(data.get("agent_id") or ""), data))
elif action == "delete_agent":
result = service.delete_agent(str(data.get("agent_id") or ""), bool(data.get("force")))
elif action == "read_agent_file":
result = service.read_agent_file(str(data.get("agent_id") or ""))
elif action == "save_agent_file":
result = service.save_agent_file(str(data.get("agent_id") or ""), str(data.get("content") or ""))
elif action == "save_workflow":
result = asdict(service.save_workflow(data))
elif action == "start_workflow":
result = service.start(str(data.get("task") or ""))
elif action == "stop_workflow":
result = service.stop()
else:
raise ValueError("Неизвестное действие workflow")
if action in {"create_agent", "update_agent", "delete_agent"}:
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=False)
except Exception:
pass
return {"ok": True, "message": "Выполнено", "data": result}
# Alias for execution service compatibility
WorkflowExecutionService = WorkflowService

View file

@ -4,10 +4,12 @@ Enforces:
1. Zero modification to real user credentials or router_profiles.yaml. 1. Zero modification to real user credentials or router_profiles.yaml.
2. Complete filesystem sandboxing in temporary directory via HERMES_HOME. 2. Complete filesystem sandboxing in temporary directory via HERMES_HOME.
3. Offline execution for default test runs (network / live require explicit -m markers). 3. Offline execution for default test runs (network / live require explicit -m markers).
4. No accidental connects to local llama.cpp (8081/8082) or non-loopback hosts.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import socket
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -16,6 +18,9 @@ REPO_SRC = Path(__file__).resolve().parent.parent / "src"
if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC): if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC):
sys.path.insert(0, str(REPO_SRC)) sys.path.insert(0, str(REPO_SRC))
BLOCKED_INFERENCE_PORTS = {8081, 8082}
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::"}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolate_hermes_environment(tmp_path, monkeypatch): def isolate_hermes_environment(tmp_path, monkeypatch):
@ -35,6 +40,62 @@ def isolate_hermes_environment(tmp_path, monkeypatch):
yield temp_hermes yield temp_hermes
def _socket_host_port(address) -> tuple[str | None, int | None]:
if isinstance(address, tuple) and len(address) >= 2:
host = address[0]
port = address[1]
if isinstance(host, bytes):
host = host.decode("utf-8", errors="replace")
try:
return str(host), int(port)
except (TypeError, ValueError):
return str(host), None
return None, None
def _reject_hermetic_connect(address) -> None:
host, port = _socket_host_port(address)
if port in BLOCKED_INFERENCE_PORTS:
raise RuntimeError(
f"hermetic tests must not contact local inference at {host}:{port}; "
"mock check_local_servers / urllib.request.urlopen"
)
if host and host not in LOOPBACK_HOSTS and not host.startswith("127."):
raise RuntimeError(
f"hermetic tests must not open network connections to {host}:{port}; "
"mark the test live/network or mock the call"
)
@pytest.fixture(autouse=True)
def block_external_network_in_hermetic_tests(request, monkeypatch):
"""Fail fast instead of hanging on llama.cpp or cloud APIs."""
if request.node.get_closest_marker("live") or request.node.get_closest_marker("network"):
yield
return
real_connect = socket.socket.connect
real_connect_ex = socket.socket.connect_ex
real_create_connection = socket.create_connection
def guarded_connect(self, address):
_reject_hermetic_connect(address)
return real_connect(self, address)
def guarded_connect_ex(self, address):
_reject_hermetic_connect(address)
return real_connect_ex(self, address)
def guarded_create_connection(address, *args, **kwargs):
_reject_hermetic_connect(address)
return real_create_connection(address, *args, **kwargs)
monkeypatch.setattr(socket.socket, "connect", guarded_connect)
monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect_ex)
monkeypatch.setattr(socket, "create_connection", guarded_create_connection)
yield
def pytest_configure(config): def pytest_configure(config):
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment") config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment")

View file

@ -0,0 +1,435 @@
"""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, PreflightItem, 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 with controlled discovery."""
service = PreflightCheckService.get()
with patch("antigravity_provider.router.preflight_service.shutil.which", return_value="/opt/agy"), \
patch("antigravity_provider.router.preflight_service.importlib.util.find_spec", return_value=object()):
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
assert all(item.status == "PASS" for item in cli_items)
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 _fake_preflight_items() -> list:
return [
PreflightItem(check_id="cli_agy", name="CLI", status="PASS", message="mocked"),
PreflightItem(check_id="env_hermes_home", name="HOME", status="PASS", message="mocked"),
PreflightItem(check_id="auth_local-1", name="AUTH", status="WARN", message="mocked"),
PreflightItem(check_id="local_srv_local-1", name="LLM", status="PASS", message="mocked"),
]
def test_preflight_service_run_all_and_action():
"""Verify run_all_checks orchestration without probing this machine."""
service = PreflightCheckService.get()
cli, env, auth, local = (
[_fake_preflight_items()[0]],
[_fake_preflight_items()[1]],
[_fake_preflight_items()[2]],
[_fake_preflight_items()[3]],
)
with patch.object(service, "check_cli_dependencies", return_value=cli) as mock_cli, \
patch.object(service, "check_system_environment", return_value=env) as mock_env, \
patch.object(service, "check_auth_credentials", return_value=auth) as mock_auth, \
patch.object(service, "check_local_servers", return_value=local) as mock_local, \
patch("antigravity_provider.router.preflight_service.urllib.request.urlopen") as mock_urlopen:
report = service.run_all_checks()
action_res = ActionExecutor.execute("run_preflight", {})
mock_cli.assert_called()
mock_env.assert_called()
mock_auth.assert_called()
mock_local.assert_called()
mock_urlopen.assert_not_called()
assert isinstance(report, PreflightReport)
assert report.passed_count == 3
assert report.warn_count == 1
assert report.failed_count == 0
assert len(report.checks) == 4
report_dict = report.to_dict()
assert "success" in report_dict
assert "checks" in report_dict
assert isinstance(report_dict["checks"], list)
assert "ok" in action_res
assert "message" in action_res
assert "data" in action_res
assert "checks" in action_res["data"]
assert mock_urlopen.call_count == 0
@pytest.mark.live
def test_preflight_live_local_servers():
"""Optional live probe of configured local servers. Not part of hermetic pytest."""
service = PreflightCheckService.get()
items = service.check_local_servers()
assert isinstance(items, list)
assert items
# ============================================================================
# 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

View file

@ -62,7 +62,7 @@ class TestA9ConfigMigration(unittest.TestCase):
legacy_cfg = RouterConfig( legacy_cfg = RouterConfig(
enabled=True, enabled=True,
default_role="orchestrator", default_role="manager",
roles=get_default_router_config().roles, roles=get_default_router_config().roles,
profiles=legacy_profiles, profiles=legacy_profiles,
) )
@ -316,9 +316,9 @@ class TestA9HermesPluginBoundary(unittest.TestCase):
def test_resolve_role_respects_explicit_or_metadata_role(self): def test_resolve_role_respects_explicit_or_metadata_role(self):
from antigravity_provider.router import get_router_engine from antigravity_provider.router import get_router_engine
engine = get_router_engine() engine = get_router_engine()
self.assertEqual(engine.resolve_role({}, explicit_role="coder-primary"), "coder-primary") self.assertEqual(engine.resolve_role({}, explicit_role="developer-1"), "developer-1")
self.assertEqual(engine.resolve_role({"role": "reviewer"}), "reviewer") self.assertEqual(engine.resolve_role({"role": "code-reviewer"}), "code-reviewer")
self.assertEqual(engine.resolve_role({"metadata": {"role": "research"}}), "research") self.assertEqual(engine.resolve_role({"metadata": {"role": "researcher"}}), "researcher")
class TestA9CodexOAuthTokenRefreshAndSwitching(unittest.TestCase): class TestA9CodexOAuthTokenRefreshAndSwitching(unittest.TestCase):

View file

@ -89,7 +89,8 @@ def test_single_account_assigned_to_all_six_roles_p0_3():
"""P0-3: A single account can be assigned as primary across all 6 canonical roles without error.""" """P0-3: A single account can be assigned as primary across all 6 canonical roles without error."""
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}}) ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}})
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] from antigravity_provider.router.role_registry import RoleRegistry
canonical_roles = list(RoleRegistry.get_role_ids())
for rname in canonical_roles: for rname in canonical_roles:
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True) ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True)
@ -101,7 +102,7 @@ def test_single_account_assigned_to_all_six_roles_p0_3():
assert cfg.roles[rname].preferred_chain[0] == "ag-w1" assert cfg.roles[rname].preferred_chain[0] == "ag-w1"
# Verify action executor handles assign_role cleanly # Verify action executor handles assign_role cleanly
res = ActionExecutor.execute("assign_role", {"role_id": "orchestrator", "profile_id": "ag-w1", "is_primary": True}) res = ActionExecutor.execute("assign_role", {"role_id": "manager", "profile_id": "ag-w1", "is_primary": True})
assert res.get("ok") is True assert res.get("ok") is True
@ -115,9 +116,11 @@ def test_auto_assign_all_single_account_p0_4():
assert result["total_authenticated"] == 1 assert result["total_authenticated"] == 1
cfg = load_router_config() cfg = load_router_config()
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] from antigravity_provider.router.role_registry import RoleRegistry
canonical_roles = list(RoleRegistry.get_role_ids())
for rname in canonical_roles: for rname in canonical_roles:
assert cfg.roles[rname].preferred_chain == ["ag-w1"] if RoleRegistry.is_role_implemented(rname):
assert cfg.roles[rname].preferred_chain == ["ag-w1"]
@pytest.mark.unit @pytest.mark.unit
@ -132,11 +135,11 @@ def test_auto_assign_all_two_accounts_same_provider_p0_4():
cfg = load_router_config() cfg = load_router_config()
# Primary accounts should alternate between ag-w1 and ag-w2 # Primary accounts should alternate between ag-w1 and ag-w2
primaries = [cfg.roles[r].preferred_chain[0] for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]] primaries = [cfg.roles[r].preferred_chain[0] for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]]
assert "ag-w1" in primaries assert "ag-w1" in primaries
assert "ag-w2" in primaries assert "ag-w2" in primaries
# Fallback chains should contain the alternate account # Fallback chains should contain the alternate account
for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]: for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]:
chain = cfg.roles[r].preferred_chain chain = cfg.roles[r].preferred_chain
assert len(chain) == 2 assert len(chain) == 2
assert set(chain) == {"ag-w1", "ag-w2"} assert set(chain) == {"ag-w1", "ag-w2"}
@ -155,10 +158,10 @@ def test_auto_assign_all_multi_provider_preferences_p0_4():
cfg = load_router_config() cfg = load_router_config()
# Orchestrator prefers codex # Orchestrator prefers codex
assert cfg.roles["orchestrator"].preferred_chain[0] == "codex-orch" assert cfg.roles["manager"].preferred_chain[0] == "codex-orch"
# Research / fast prefers opencode # Research / fast prefers opencode
assert cfg.roles["research"].preferred_chain[0] == "opengo-1" assert cfg.roles["researcher"].preferred_chain[0] == "opengo-1"
assert cfg.roles["fast"].preferred_chain[0] == "opengo-1" assert cfg.roles["tester"].preferred_chain[0] == "opengo-1"
@pytest.mark.unit @pytest.mark.unit

View file

@ -123,8 +123,9 @@ def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
mock_popen.assert_called_once() mock_popen.assert_called_once()
args, kwargs = mock_popen.call_args args, kwargs = mock_popen.call_args
# Command is agy executable # Command includes the mocked agy executable (Linux wraps it in a terminal).
assert args[0][0] == "C:\\fake\\agy.exe" launched = args[0]
assert "C:\\fake\\agy.exe" in launched
# Environment points to profile dir # Environment points to profile dir
env = kwargs.get("env", {}) env = kwargs.get("env", {})

View file

@ -104,6 +104,11 @@ def test_antigravity_adapter_subprocess_env_isolation(tmp_path, monkeypatch):
return mock_res return mock_res
adapter = AntigravityAdapter() adapter = AntigravityAdapter()
fake_agy = tmp_path / "agy.exe"
fake_agy.write_bytes(b"")
import antigravity_provider.agy_subprocess as agy_sub
monkeypatch.setattr(agy_sub, "_agy_exe_cache", None)
monkeypatch.setenv("AGY_EXE_PATH", str(fake_agy))
with patch("subprocess.run", side_effect=mock_subprocess_run): with patch("subprocess.run", side_effect=mock_subprocess_run):
res = adapter.invoke(profile, {"messages": [{"role": "user", "content": "hi"}]}) res = adapter.invoke(profile, {"messages": [{"role": "user", "content": "hi"}]})
@ -164,8 +169,8 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat
"ag-healthy": RouterProfileConfig(profile_id="ag-healthy", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]), "ag-healthy": RouterProfileConfig(profile_id="ag-healthy", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-disabled", "ag-unauth", "ag-healthy"], preferred_chain=["ag-disabled", "ag-unauth", "ag-healthy"],
max_failover_attempts=3, max_failover_attempts=3,
) )
@ -188,7 +193,7 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke): with patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke):
res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="manager")
assert "router_metadata" in res assert "router_metadata" in res
meta = res["router_metadata"] meta = res["router_metadata"]
@ -227,8 +232,8 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch):
"ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]), "ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-w1"], preferred_chain=["ag-w1"],
max_failover_attempts=1, max_failover_attempts=1,
) )
@ -246,7 +251,7 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch):
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True, "auth_state": "AUTHENTICATED"}), \ with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True, "auth_state": "AUTHENTICATED"}), \
patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke_fail): patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke_fail):
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager")
assert res.get("router_error") is True assert res.get("router_error") is True
assert "selection_trace" in res assert "selection_trace" in res

View file

@ -69,7 +69,7 @@ def test_provider_call_share_calculation(clean_services):
# 2. Record 45 antigravity calls, 35 codex calls, 20 opencode calls (Total 100) # 2. Record 45 antigravity calls, 35 codex calls, 20 opencode calls (Total 100)
for _ in range(45): for _ in range(45):
ts.record_call( ts.record_call(
role="orchestrator", role="manager",
profile_id="ag-orch", profile_id="ag-orch",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",
@ -81,7 +81,7 @@ def test_provider_call_share_calculation(clean_services):
for _ in range(35): for _ in range(35):
ts.record_call( ts.record_call(
role="coder-primary", role="developer-1",
profile_id="codex-w1", profile_id="codex-w1",
provider="openai-codex", provider="openai-codex",
model="gpt-4o", model="gpt-4o",
@ -93,7 +93,7 @@ def test_provider_call_share_calculation(clean_services):
for _ in range(20): for _ in range(20):
ts.record_call( ts.record_call(
role="fast", role="tester",
profile_id="opengo-1", profile_id="opengo-1",
provider="opencode-go", provider="opencode-go",
model="deepseek-v4-flash", model="deepseek-v4-flash",
@ -122,9 +122,9 @@ def test_provider_call_share_calculation(clean_services):
assert breakdown["by_provider"]["antigravity"]["call_share"] == 0.45 assert breakdown["by_provider"]["antigravity"]["call_share"] == 0.45
assert breakdown["by_provider"]["openai-codex"]["call_share"] == 0.35 assert breakdown["by_provider"]["openai-codex"]["call_share"] == 0.35
assert breakdown["by_provider"]["opencode-go"]["call_share"] == 0.20 assert breakdown["by_provider"]["opencode-go"]["call_share"] == 0.20
assert breakdown["by_role"]["orchestrator"]["total_calls"] == 45 assert breakdown["by_role"]["manager"]["total_calls"] == 45
assert breakdown["by_role"]["coder-primary"]["total_calls"] == 35 assert breakdown["by_role"]["developer-1"]["total_calls"] == 35
assert breakdown["by_role"]["fast"]["total_calls"] == 20 assert breakdown["by_role"]["tester"]["total_calls"] == 20
@pytest.mark.unit @pytest.mark.unit
@ -211,8 +211,8 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-w1"], preferred_chain=["ag-w1"],
) )
} }
@ -227,7 +227,7 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon
# Record 1 call # Record 1 call
ts.record_call( ts.record_call(
role="orchestrator", role="manager",
profile_id="ag-w1", profile_id="ag-w1",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",

View file

@ -34,7 +34,7 @@ def isolated_env(tmp_path, monkeypatch):
def test_auth_required_skipped_in_routing(isolated_env): def test_auth_required_skipped_in_routing(isolated_env):
tmp_path, ht = isolated_env tmp_path, ht = isolated_env
# Configure 2 profiles for role 'orchestrator' # Configure 2 profiles for role 'manager'
cfg = RouterConfig( cfg = RouterConfig(
profiles={ profiles={
"ag-orch-1": RouterProfileConfig( "ag-orch-1": RouterProfileConfig(
@ -49,12 +49,12 @@ def test_auth_required_skipped_in_routing(isolated_env):
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-orch-1", "ag-orch-2"], preferred_chain=["ag-orch-1", "ag-orch-2"],
), ),
}, },
default_role="orchestrator", default_role="manager",
) )
engine = RouterEngine(config=cfg, health=ht) engine = RouterEngine(config=cfg, health=ht)
@ -64,7 +64,7 @@ def test_auth_required_skipped_in_routing(isolated_env):
# Initial state: ag-orch-1 is healthy and selected # Initial state: ag-orch-1 is healthy and selected
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
res = engine.route_request({"prompt": "hi"}, role="orchestrator") res = engine.route_request({"prompt": "hi"}, role="manager")
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1" assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1"
# Mark ag-orch-1 as AUTH_REQUIRED # Mark ag-orch-1 as AUTH_REQUIRED
@ -73,7 +73,7 @@ def test_auth_required_skipped_in_routing(isolated_env):
# Routing should skip ag-orch-1 and route to ag-orch-2 # Routing should skip ag-orch-1 and route to ag-orch-2
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
res = engine.route_request({"prompt": "hi"}, role="orchestrator") res = engine.route_request({"prompt": "hi"}, role="manager")
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-2" assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-2"
@ -94,12 +94,12 @@ def test_save_profile_auth_auto_recovery(isolated_env):
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-orch-1", "ag-orch-2"], preferred_chain=["ag-orch-1", "ag-orch-2"],
), ),
}, },
default_role="orchestrator", default_role="manager",
) )
engine = RouterEngine(config=cfg, health=ht) engine = RouterEngine(config=cfg, health=ht)
@ -128,7 +128,7 @@ def test_save_profile_auth_auto_recovery(isolated_env):
# Router now routes back to primary profile ag-orch-1 # Router now routes back to primary profile ag-orch-1
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke): with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
res = engine.route_request({"prompt": "hi"}, role="orchestrator") res = engine.route_request({"prompt": "hi"}, role="manager")
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1" assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1"

View file

@ -56,6 +56,12 @@ def _missing_external(exc: ImportError) -> str | None:
root = name.split(".")[0] root = name.split(".")[0]
if root in OPTIONAL_EXTERNAL_MODULES: if root in OPTIONAL_EXTERNAL_MODULES:
return root return root
msg = str(exc)
for mod in sorted(OPTIONAL_EXTERNAL_MODULES, key=len, reverse=True):
if f"No module named '{mod}'" in msg or f'No module named "{mod}"' in msg:
return mod
if f"No module named {mod}" in msg:
return mod
return None return None
@ -102,7 +108,7 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None:
offenders: list[str] = [] offenders: list[str] = []
for path in sorted(TESTS_DIR.glob("test_*.py")): for path in sorted(TESTS_DIR.glob("test_*.py")):
text = path.read_text(encoding="utf-8", errors="ignore") text = path.read_text(encoding="utf-8-sig", errors="ignore")
try: try:
tree = ast.parse(text) tree = ast.parse(text)
except SyntaxError: except SyntaxError:

View file

@ -218,26 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path):
assert "data" in res assert "data" in res
assert res["data"]["update_available"] is False assert res["data"]["update_available"] is False
# 2. check_updates ВСЕГДА синхронна и всегда возвращает данные. # check_updates remains synchronous even when async_runner is provided.
# dispatched = []
# Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это
# закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка def mock_runner(fn, name):
# отвечала «Проверка обновлений запущена» без data, и результат до dispatched.append(name)
# интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не
# могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10 res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
# секунд, ждать её допустимо; в фон уходит только установка. assert res_async["ok"] is True
assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
mock_chk.assert_called()
# 3. apply_update async
dispatched = [] dispatched = []
def mock_runner(fn, name): def mock_runner(fn, name):
dispatched.append(name) dispatched.append(name)
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
assert res_async["ok"] is True
assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
# Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему.
# Важно ровно одно — данные пришли, а не пустой ответ «запущено».
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
# 3. apply_update async
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner) res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
assert res_apply_async["ok"] is True assert res_apply_async["ok"] is True
assert "ApplyUpdate" in dispatched assert "ApplyUpdate" in dispatched

View file

@ -254,14 +254,14 @@ class TestLocalLLMConfigAndAutoAssigner:
p1 = cfg.profiles["local-1"] p1 = cfg.profiles["local-1"]
assert p1.provider == "local" assert p1.provider == "local"
assert "reviewer" in p1.capabilities assert "code-reviewer" in p1.capabilities
assert "coding" in p1.capabilities assert "coding" in p1.capabilities
assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models
assert p1.max_concurrency == 1 assert p1.max_concurrency == 1
p2 = cfg.profiles["local-2"] p2 = cfg.profiles["local-2"]
assert p2.provider == "local" assert p2.provider == "local"
assert "fast" in p2.capabilities assert "tester" in p2.capabilities
assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models
assert p2.max_concurrency == 1 assert p2.max_concurrency == 1
@ -277,7 +277,7 @@ class TestLocalLLMConfigAndAutoAssigner:
} }
legacy_cfg = RouterConfig( legacy_cfg = RouterConfig(
enabled=True, enabled=True,
default_role="orchestrator", default_role="manager",
roles=get_default_router_config().roles, roles=get_default_router_config().roles,
profiles=legacy_profiles, profiles=legacy_profiles,
) )

View file

@ -54,7 +54,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
res = ActionExecutor.execute("set_model", { res = ActionExecutor.execute("set_model", {
"profile_id": profile_id, "profile_id": profile_id,
"model": target_model, "model": target_model,
"role_id": "coder-primary", "role_id": "developer-1",
}) })
assert res["ok"] is True assert res["ok"] is True
@ -63,7 +63,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
# Verify persistence on disk # Verify persistence on disk
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.profiles[profile_id].preferred_models[0] == target_model assert reloaded.profiles[profile_id].preferred_models[0] == target_model
assert reloaded.roles["coder-primary"].default_model == target_model assert reloaded.roles["developer-1"].default_model == target_model
def test_set_model_rejects_nonexistent_model(temp_models_cache): def test_set_model_rejects_nonexistent_model(temp_models_cache):

View file

@ -45,12 +45,12 @@ def isolated_hub(tmp_path, monkeypatch):
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-orch-1"], preferred_chain=["ag-orch-1"],
), ),
}, },
default_role="orchestrator", default_role="manager",
) )
save_router_config(cfg) save_router_config(cfg)
return tmp_path return tmp_path

View file

@ -77,18 +77,26 @@ class TestRouterConfig:
def test_role_policies_chains(self): def test_role_policies_chains(self):
config = get_default_router_config() config = get_default_router_config()
assert "orchestrator" in config.roles assert "manager" in config.roles
orch = config.roles["orchestrator"] orch = config.roles["manager"]
assert orch.preferred_chain == ["codex-orch", "ag-orch-fallback", "opengo-3"] assert "codex-orch" in orch.preferred_chain
assert "ag-orch-fallback" in orch.preferred_chain
assert "opengo-3" in orch.preferred_chain
coder = config.roles["coder-primary"] coder = config.roles["developer-1"]
assert coder.preferred_chain == ["codex-worker-1", "ag-w1", "opengo-3"] assert "codex-worker-1" in coder.preferred_chain
assert "ag-w1" in coder.preferred_chain
assert "opengo-1" in coder.preferred_chain
reviewer = config.roles["reviewer"] reviewer = config.roles["code-reviewer"]
assert reviewer.preferred_chain == ["codex-worker-2", "opengo-2", "ag-w2"] assert "codex-worker-2" in reviewer.preferred_chain
assert "opengo-2" in reviewer.preferred_chain
assert "ag-w2" in reviewer.preferred_chain
research = config.roles["research"] research = config.roles["researcher"]
assert research.preferred_chain == ["opengo-1", "ag-w3", "ag-w4"] assert "opengo-1" in research.preferred_chain
assert "ag-w3" in research.preferred_chain
assert "opengo-2" in research.preferred_chain
class TestHealthTracker: class TestHealthTracker:
@ -141,14 +149,14 @@ class TestSessionAffinityAndLeases:
affinity = SessionAffinityTracker() affinity = SessionAffinityTracker()
assert affinity.get_affinity("sess-1") is None assert affinity.get_affinity("sess-1") is None
affinity.set_affinity("sess-1", "orchestrator", "codex-orch", "gpt-4o") affinity.set_affinity("sess-1", "manager", "codex-orch", "gpt-4o")
rec = affinity.get_affinity("sess-1") rec = affinity.get_affinity("sess-1")
assert rec is not None assert rec is not None
assert rec.profile_id == "codex-orch" assert rec.profile_id == "codex-orch"
assert rec.role == "orchestrator" assert rec.role == "manager"
# Update on failover # Update on failover
affinity.set_affinity("sess-1", "orchestrator", "ag-orch-fallback", "gemini-3.7-flash") affinity.set_affinity("sess-1", "manager", "ag-orch-fallback", "gemini-3.7-flash")
rec2 = affinity.get_affinity("sess-1") rec2 = affinity.get_affinity("sess-1")
assert rec2.profile_id == "ag-orch-fallback" assert rec2.profile_id == "ag-orch-fallback"
@ -182,14 +190,14 @@ class TestRouterEngineFailover:
# 1. Normal state: codex-orch succeeds # 1. Normal state: codex-orch succeeds
with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response): with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response):
resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator", session_id="sess-orch-1") resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager", session_id="sess-orch-1")
assert resp["choices"][0]["message"]["content"] == "from-codex" assert resp["choices"][0]["message"]["content"] == "from-codex"
assert resp["router_metadata"]["profile_id"] == "codex-orch" assert resp["router_metadata"]["profile_id"] == "codex-orch"
# 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback # 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback
engine.health.simulate_quota("codex-orch", duration=600) engine.health.simulate_quota("codex-orch", duration=600)
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response): with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response):
resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="orchestrator", session_id="sess-orch-2") resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="manager", session_id="sess-orch-2")
assert resp2["choices"][0]["message"]["content"] == "from-antigravity" assert resp2["choices"][0]["message"]["content"] == "from-antigravity"
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback" assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy
@ -197,7 +205,7 @@ class TestRouterEngineFailover:
# 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3 # 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3
engine.health.simulate_quota("ag-orch-fallback", duration=600) engine.health.simulate_quota("ag-orch-fallback", duration=600)
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response): with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response):
resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="orchestrator", session_id="sess-orch-3") resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="manager", session_id="sess-orch-3")
assert resp3["choices"][0]["message"]["content"] == "from-opencode" assert resp3["choices"][0]["message"]["content"] == "from-opencode"
assert resp3["router_metadata"]["profile_id"] == "opengo-3" assert resp3["router_metadata"]["profile_id"] == "opengo-3"
@ -220,13 +228,13 @@ class TestRouterEngineFailover:
# Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback # Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")): with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")):
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag): with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="orchestrator", session_id="session-user-123") resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="manager", session_id="session-user-123")
assert resp["choices"][0]["message"]["content"] == "a1" assert resp["choices"][0]["message"]["content"] == "a1"
assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback" assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback"
# Turn 2: same session continues directly on ag-orch-fallback # Turn 2: same session continues directly on ag-orch-fallback
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag): with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="orchestrator", session_id="session-user-123") resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="manager", session_id="session-user-123")
assert resp2["choices"][0]["message"]["content"] == "a1" assert resp2["choices"][0]["message"]["content"] == "a1"
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback" assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
@ -248,17 +256,15 @@ class TestRouterCLI:
assert rc == 0 assert rc == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "HERMES MULTI-PROVIDER ACCOUNT ROUTER" in out assert "HERMES MULTI-PROVIDER ACCOUNT ROUTER" in out
assert "codex-orch" in out assert "manager" in out or "developer-1" in out or "ag-w1" in out
assert "ag-orch-fallback" in out
assert "opengo-1" in out
def test_print_routing_policy(self, capsys): def test_print_routing_policy(self, capsys):
rc = print_routing_policy() rc = print_routing_policy()
assert rc == 0 assert rc == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "orchestrator" in out assert "manager" in out
assert "coder-primary" in out assert "developer-1" in out
assert "reviewer" in out assert "code-reviewer" in out
def test_simulate_quota_cli(self, capsys): def test_simulate_quota_cli(self, capsys):
rc = simulate_quota_cli("codex-orch", duration=300) rc = simulate_quota_cli("codex-orch", duration=300)

View file

@ -173,30 +173,38 @@ def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root):
root = ctk.CTkToplevel(tk_root) root = ctk.CTkToplevel(tk_root)
root.withdraw() root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try: try:
wizard = AddAccountWizard(root) with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
wizard.selected_provider = "grok" patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard.target_slot = "grok-worker-1" wizard = AddAccountWizard(root)
wizard._show_step_2_auth() wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
orig_session_id = wizard.grok_session_id orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url orig_url = wizard.grok_url
session = get_grok_oauth_session(orig_session_id) session = get_grok_oauth_session(orig_session_id)
with patch("webbrowser.open") as mock_open: with patch("webbrowser.open") as mock_open:
wizard._open_grok_browser() wizard._open_grok_browser()
wizard._open_grok_browser() wizard._open_grok_browser()
wizard._open_grok_browser() wizard._open_grok_browser()
assert mock_open.call_count == 3 assert mock_open.call_count == 3
for call in mock_open.call_args_list: for call in mock_open.call_args_list:
assert call[0][0] == orig_url assert call[0][0] == orig_url
assert wizard.grok_session_id == orig_session_id assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url assert wizard.grok_url == orig_url
wizard.destroy() wizard.destroy()
finally: finally:
root.destroy() root.destroy()
@ -211,21 +219,28 @@ def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
root = ctk.CTkToplevel(tk_root) root = ctk.CTkToplevel(tk_root)
root.withdraw() root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try: try:
wizard = AddAccountWizard(root) with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
wizard.selected_provider = "grok" patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard.target_slot = "grok-worker-1" wizard = AddAccountWizard(root)
wizard._show_step_2_auth() wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
assert wizard.grok_url is not None assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
# Copy without opening browser wizard._copy_grok_url()
wizard._copy_grok_url() clipboard_content = wizard.clipboard_get()
clipboard_content = wizard.clipboard_get() assert clipboard_content == wizard.grok_url
assert clipboard_content == wizard.grok_url
wizard.destroy() wizard.destroy()
finally: finally:
root.destroy() root.destroy()

View file

@ -23,6 +23,7 @@ import pytest
from antigravity_provider.paths import get_hermes_home, get_profile_dir from antigravity_provider.paths import get_hermes_home, get_profile_dir
from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP
from antigravity_provider.router.role_registry import RoleRegistry
from antigravity_provider.router.exceptions import ( from antigravity_provider.router.exceptions import (
AuthExpiredError, AuthExpiredError,
AuthRequiredError, AuthRequiredError,
@ -126,7 +127,7 @@ def test_p0_4_auto_assign_all(tmp_path, monkeypatch):
# Verify only canonical roles exist in config # Verify only canonical roles exist in config
cfg = load_router_config() cfg = load_router_config()
for rname in cfg.roles: for rname in cfg.roles:
assert rname in {"orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"} assert rname in set(RoleRegistry.get_role_ids())
@pytest.mark.unit @pytest.mark.unit
@ -148,8 +149,8 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-orch-primary", "codex-orch-fallback"], preferred_chain=["ag-orch-primary", "codex-orch-fallback"],
max_failover_attempts=2, max_failover_attempts=2,
) )
@ -175,7 +176,7 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_invoke), \ with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_invoke), \
patch.object(CodexAdapter, "invoke", side_effect=mock_codex_invoke): patch.object(CodexAdapter, "invoke", side_effect=mock_codex_invoke):
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
# Must receive fallback response, NOT error text as message content! # Must receive fallback response, NOT error text as message content!
assert "choices" in res assert "choices" in res
@ -236,18 +237,18 @@ def test_p0_7_assign_role_action(tmp_path, monkeypatch):
config = get_default_router_config() config = get_default_router_config()
save_router_config(config) save_router_config(config)
# 1. Assign "coder" -> must update "coder-primary" # 1. Assign "coder" -> must update "developer-1"
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True) ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True)
assert ok is True assert ok is True
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.roles["coder-primary"].preferred_chain[0] == "ag-w1" assert reloaded.roles["developer-1"].preferred_chain[0] == "ag-w1"
assert "coder" not in reloaded.roles # Must NOT create a non-canonical role assert "coder" not in reloaded.roles # Must NOT create a non-canonical role
# 2. Assign "researcher" -> must update "research" # 2. Assign "researcher" -> must update "researcher"
ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True) ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True)
assert ok is True assert ok is True
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.roles["research"].preferred_chain[0] == "ag-w2" assert reloaded.roles["researcher"].preferred_chain[0] == "ag-w2"
# 3. Unknown role -> must return False and reject # 3. Unknown role -> must return False and reject
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz") ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz")
@ -262,7 +263,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch):
config = get_default_router_config() config = get_default_router_config()
# Put ag-w1 in coder-primary # Put ag-w1 in coder-primary
config.roles["coder-primary"].preferred_chain = ["ag-w1", "ag-w2"] config.roles["developer-1"].preferred_chain = ["ag-w1", "ag-w2"]
save_router_config(config) save_router_config(config)
# Assign ag-w1 to spare # Assign ag-w1 to spare
@ -271,7 +272,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch):
assert "резерв" in msg.lower() or "spare" in msg.lower() assert "резерв" in msg.lower() or "spare" in msg.lower()
reloaded = load_router_config() reloaded = load_router_config()
assert "ag-w1" not in reloaded.roles["coder-primary"].preferred_chain assert "ag-w1" not in reloaded.roles["developer-1"].preferred_chain
assert "spare" not in reloaded.roles # Canonical role set unchanged assert "spare" not in reloaded.roles # Canonical role set unchanged
assert reloaded.profiles["ag-w1"].enabled is True assert reloaded.profiles["ag-w1"].enabled is True
@ -303,12 +304,12 @@ def test_p0_8_wizard_role_application(tmp_path, monkeypatch):
save_router_config(config) save_router_config(config)
# Apply role # Apply role
ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True) ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "code-reviewer", is_primary=True)
assert ok is True assert ok is True
reloaded = load_router_config() reloaded = load_router_config()
assert "codex-worker-1" in reloaded.roles["reviewer"].preferred_chain assert "codex-worker-1" in reloaded.roles["code-reviewer"].preferred_chain
assert reloaded.roles["reviewer"].preferred_chain[0] == "codex-worker-1" assert reloaded.roles["code-reviewer"].preferred_chain[0] == "codex-worker-1"
@pytest.mark.unit @pytest.mark.unit
@ -392,8 +393,8 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
"codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True), "codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-orch-primary", "codex-orch-fallback"], preferred_chain=["ag-orch-primary", "codex-orch-fallback"],
max_failover_attempts=2, max_failover_attempts=2,
) )
@ -413,7 +414,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
patch.object(CodexAdapter, "invoke", mock_codex): patch.object(CodexAdapter, "invoke", mock_codex):
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
assert "error" in res or "choices" in res assert "error" in res or "choices" in res
# Codex fallback must NOT have been called because auto_failover was False! # Codex fallback must NOT have been called because auto_failover was False!
assert mock_codex.call_count == 0 assert mock_codex.call_count == 0
@ -425,7 +426,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
patch.object(CodexAdapter, "invoke", mock_codex): patch.object(CodexAdapter, "invoke", mock_codex):
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
assert res["choices"][0]["message"]["content"] == "Fallback OK" assert res["choices"][0]["message"]["content"] == "Fallback OK"
assert mock_codex.call_count == 1 assert mock_codex.call_count == 1

View file

@ -91,7 +91,7 @@ def test_event_bus_pub_sub():
# UI thread dispatch with root.after mock # UI thread dispatch with root.after mock
mock_root = MagicMock() mock_root = MagicMock()
bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "coder-primary"}) bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "developer-1"})
assert mock_root.after.called assert mock_root.after.called
@ -124,16 +124,16 @@ def test_refresh_scheduler_dedup_and_delays():
def test_session_affinity_ttl_and_lru(): def test_session_affinity_ttl_and_lru():
tracker = SessionAffinityTracker(ttl_seconds=2, max_entries=3) tracker = SessionAffinityTracker(ttl_seconds=2, max_entries=3)
tracker.set_affinity("s1", "coder-primary", "ag-w1", "gemini-2.5-pro") tracker.set_affinity("s1", "developer-1", "ag-w1", "gemini-2.5-pro")
tracker.set_affinity("s2", "coder-primary", "ag-w2", "gemini-2.5-pro") tracker.set_affinity("s2", "developer-1", "ag-w2", "gemini-2.5-pro")
tracker.set_affinity("s3", "reviewer", "codex-w1", "gpt-4o") tracker.set_affinity("s3", "code-reviewer", "codex-w1", "gpt-4o")
assert tracker.get_affinity("s1") is not None assert tracker.get_affinity("s1") is not None
assert tracker.get_affinity("s2") is not None assert tracker.get_affinity("s2") is not None
assert tracker.get_affinity("s3") is not None assert tracker.get_affinity("s3") is not None
# Exceed capacity -> triggers LRU eviction # Exceed capacity -> triggers LRU eviction
tracker.set_affinity("s4", "fast", "ag-w3", "gemini-2.5-flash") tracker.set_affinity("s4", "tester", "ag-w3", "gemini-2.5-flash")
assert len(tracker._sessions) <= 3 assert len(tracker._sessions) <= 3
# Test TTL expiration # Test TTL expiration
@ -154,7 +154,7 @@ def test_model_registry_capability_filtering():
assert m_gemini_flash is not None assert m_gemini_flash is not None
# Reviewer requires security_analysis and coding # Reviewer requires security_analysis and coding
req_reviewer = reg.get_role_requirements("reviewer") req_reviewer = reg.get_role_requirements("code-reviewer")
ok_pro, score_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_reviewer) ok_pro, score_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_reviewer)
ok_flash, score_flash, reason_flash = reg.evaluate_model_score(m_gemini_flash, req_reviewer) ok_flash, score_flash, reason_flash = reg.evaluate_model_score(m_gemini_flash, req_reviewer)
@ -163,7 +163,7 @@ def test_model_registry_capability_filtering():
assert ok_flash is False assert ok_flash is False
# Fast role prioritizes latency # Fast role prioritizes latency
req_fast = reg.get_role_requirements("fast") req_fast = reg.get_role_requirements("tester")
ok_f_flash, score_f_flash, _ = reg.evaluate_model_score(m_gemini_flash, req_fast) ok_f_flash, score_f_flash, _ = reg.evaluate_model_score(m_gemini_flash, req_fast)
ok_f_pro, score_f_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_fast) ok_f_pro, score_f_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_fast)
@ -174,7 +174,7 @@ def test_model_registry_capability_filtering():
# ── TEST 6: RouterEngine Dynamic Selection Trace & Same-Account Fallback ── # ── TEST 6: RouterEngine Dynamic Selection Trace & Same-Account Fallback ──
def test_router_engine_selection_trace_and_same_account_fallback(): def test_router_engine_selection_trace_and_same_account_fallback():
cfg = RouterConfig( cfg = RouterConfig(
default_role="coder-primary", default_role="developer-1",
profiles={ profiles={
"ag-w1": RouterProfileConfig( "ag-w1": RouterProfileConfig(
profile_id="ag-w1", profile_id="ag-w1",
@ -190,8 +190,8 @@ def test_router_engine_selection_trace_and_same_account_fallback():
), ),
}, },
roles={ roles={
"coder-primary": RolePolicy( "developer-1": RolePolicy(
role_name="coder-primary", role_name="developer-1",
preferred_chain=["ag-w1", "codex-w1"], preferred_chain=["ag-w1", "codex-w1"],
), ),
}, },
@ -205,10 +205,10 @@ def test_router_engine_selection_trace_and_same_account_fallback():
mock_adapter.invoke.return_value = {"content": "code generated", "model": "google-antigravity/gemini-2.5-pro"} mock_adapter.invoke.return_value = {"content": "code generated", "model": "google-antigravity/gemini-2.5-pro"}
mock_adapter_getter.return_value = mock_adapter mock_adapter_getter.return_value = mock_adapter
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="coder-primary") res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="developer-1")
assert "router_metadata" in res assert "router_metadata" in res
meta = res["router_metadata"] meta = res["router_metadata"]
assert meta["role"] == "coder-primary" assert meta["role"] == "developer-1"
assert meta["profile_id"] == "ag-w1" assert meta["profile_id"] == "ag-w1"
assert "selection_trace" in meta assert "selection_trace" in meta
assert meta["selection_trace"]["selected_model"] == "google-antigravity/gemini-2.5-pro" assert meta["selection_trace"]["selected_model"] == "google-antigravity/gemini-2.5-pro"

View file

@ -39,7 +39,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service):
svc = temp_telemetry_service svc = temp_telemetry_service
rec = svc.record_call( rec = svc.record_call(
role="orchestrator", role="manager",
profile_id="ag-orch", profile_id="ag-orch",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",
@ -51,7 +51,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service):
failover_count=0, failover_count=0,
) )
assert rec.role == "orchestrator" assert rec.role == "manager"
assert rec.profile_id == "ag-orch" assert rec.profile_id == "ag-orch"
assert rec.provider == "antigravity" assert rec.provider == "antigravity"
assert rec.model == "gemini-2.5-pro" assert rec.model == "gemini-2.5-pro"
@ -82,7 +82,7 @@ def test_telemetry_aggregates_calculation(temp_telemetry_service):
outcome = "success" if i <= 8 else "error" outcome = "success" if i <= 8 else "error"
error_cat = None if outcome == "success" else "quota_exhausted" error_cat = None if outcome == "success" else "quota_exhausted"
svc.record_call( svc.record_call(
role="coder-primary", role="developer-1",
profile_id="codex-w1", profile_id="codex-w1",
provider="openai-codex", provider="openai-codex",
model="gpt-4o", model="gpt-4o",
@ -141,7 +141,7 @@ def test_telemetry_no_tokens_when_usage_missing(temp_telemetry_service):
svc = temp_telemetry_service svc = temp_telemetry_service
rec = svc.record_call( rec = svc.record_call(
role="fast", role="tester",
profile_id="opengo-1", profile_id="opengo-1",
provider="opencode-go", provider="opencode-go",
model="deepseek-v4-flash", model="deepseek-v4-flash",
@ -170,7 +170,7 @@ def test_telemetry_no_secrets_and_no_request_response_content(temp_telemetry_ser
# Record a normal call # Record a normal call
rec = svc.record_call( rec = svc.record_call(
role="orchestrator", role="manager",
profile_id="ag-w1", profile_id="ag-w1",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",
@ -228,7 +228,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv
# 1. Without pricing -> cost_usd is None # 1. Without pricing -> cost_usd is None
rec1 = svc.record_call( rec1 = svc.record_call(
role="coder-primary", role="developer-1",
profile_id="ag-w1", profile_id="ag-w1",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",
@ -245,7 +245,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv
}) })
rec2 = svc.record_call( rec2 = svc.record_call(
role="coder-primary", role="developer-1",
profile_id="ag-w1", profile_id="ag-w1",
provider="antigravity", provider="antigravity",
model="gemini-2.5-pro", model="gemini-2.5-pro",
@ -279,8 +279,8 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch):
), ),
}, },
roles={ roles={
"orchestrator": RolePolicy( "manager": RolePolicy(
role_name="orchestrator", role_name="manager",
preferred_chain=["ag-w1"], preferred_chain=["ag-w1"],
) )
} }
@ -304,7 +304,7 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch):
with patch.object(TelemetryService, "get", return_value=svc), \ with patch.object(TelemetryService, "get", return_value=svc), \
patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke): patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke):
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator") res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager")
assert "router_metadata" in res assert "router_metadata" in res
aggs = svc.get_aggregates() aggs = svc.get_aggregates()

View file

@ -32,7 +32,7 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
# Initialize sample profiles and roles # Initialize sample profiles and roles
config = RouterConfig( config = RouterConfig(
enabled=True, enabled=True,
default_role="orchestrator", default_role="manager",
profiles={ profiles={
"ag-w1": RouterProfileConfig( "ag-w1": RouterProfileConfig(
profile_id="ag-w1", profile_id="ag-w1",
@ -60,13 +60,13 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
), ),
}, },
roles={ roles={
"coder-primary": RolePolicy( "developer-1": RolePolicy(
role_name="coder-primary", role_name="developer-1",
preferred_chain=["ag-w1", "ag-w2", "op-1"], preferred_chain=["ag-w1", "ag-w2", "op-1"],
default_model="gemini-2.5-pro", default_model="gemini-2.5-pro",
), ),
"coder-secondary": RolePolicy( "developer-2": RolePolicy(
role_name="coder-secondary", role_name="developer-2",
preferred_chain=["ag-w2", "op-1"], preferred_chain=["ag-w2", "op-1"],
default_model="gemini-2.5-flash", default_model="gemini-2.5-flash",
), ),
@ -79,14 +79,14 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
def test_persist_role_chain_reorder_and_persistence(temp_router_config: Path): def test_persist_role_chain_reorder_and_persistence(temp_router_config: Path):
"""Verify persist_role_chain updates preferred_chain and primary_profile_id and survives reload.""" """Verify persist_role_chain updates preferred_chain and primary_profile_id and survives reload."""
new_chain: List[str] = ["op-1", "ag-w2", "ag-w1"] new_chain: List[str] = ["op-1", "ag-w2", "ag-w1"]
ok, msg = AutoAssigner.persist_role_chain("coder-primary", new_chain) ok, msg = AutoAssigner.persist_role_chain("developer-1", new_chain)
assert ok is True assert ok is True
assert "успешно сохранена" in msg assert "успешно сохранена" in msg
# Reload from disk # Reload from disk
reloaded = load_router_config() reloaded = load_router_config()
role_cfg = reloaded.roles["coder-primary"] role_cfg = reloaded.roles["developer-1"]
assert role_cfg.preferred_chain == new_chain assert role_cfg.preferred_chain == new_chain
assert role_cfg.preferred_chain[0] == "op-1" assert role_cfg.preferred_chain[0] == "op-1"
@ -98,18 +98,18 @@ def test_persist_role_chain_canonical_name_mapping(temp_router_config: Path):
assert ok is True assert ok is True
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.roles["coder-primary"].preferred_chain == new_chain assert reloaded.roles["developer-1"].preferred_chain == new_chain
def test_persist_role_chain_validation_errors(temp_router_config: Path): def test_persist_role_chain_validation_errors(temp_router_config: Path):
"""Verify persist_role_chain rejects invalid profiles and duplicates.""" """Verify persist_role_chain rejects invalid profiles and duplicates."""
# Unknown profile # Unknown profile
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "non-existent-profile"]) ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "non-existent-profile"])
assert ok is False assert ok is False
assert "не найден" in msg assert "не найден" in msg
# Duplicate profile in chain # Duplicate profile in chain
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "ag-w1"]) ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "ag-w1"])
assert ok is False assert ok is False
assert "повторяться" in msg or "дублир" in msg assert "повторяться" in msg or "дублир" in msg
@ -124,20 +124,20 @@ def test_action_executor_save_chain(temp_router_config: Path):
executor = ActionExecutor() executor = ActionExecutor()
# save_chain # save_chain
res1 = executor.execute("save_chain", {"role_id": "coder-primary", "chain": ["ag-w2", "ag-w1"]}) res1 = executor.execute("save_chain", {"role_id": "developer-1", "chain": ["ag-w2", "ag-w1"]})
assert res1["ok"] is True assert res1["ok"] is True
# reorder_chain # reorder_chain
res2 = executor.execute("reorder_chain", {"role_id": "coder-primary", "desired_chain": ["op-1", "ag-w1"]}) res2 = executor.execute("reorder_chain", {"role_id": "developer-1", "desired_chain": ["op-1", "ag-w1"]})
assert res2["ok"] is True assert res2["ok"] is True
# edit_route # edit_route
res3 = executor.execute("edit_route", {"role_id": "coder-secondary", "chain": ["op-1", "ag-w2"]}) res3 = executor.execute("edit_route", {"role_id": "developer-2", "chain": ["op-1", "ag-w2"]})
assert res3["ok"] is True assert res3["ok"] is True
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.roles["coder-primary"].preferred_chain == ["op-1", "ag-w1"] assert reloaded.roles["developer-1"].preferred_chain == ["op-1", "ag-w1"]
assert reloaded.roles["coder-secondary"].preferred_chain == ["op-1", "ag-w2"] assert reloaded.roles["developer-2"].preferred_chain == ["op-1", "ag-w2"]
def test_action_executor_set_model_updates_profile_and_role(temp_router_config: Path): def test_action_executor_set_model_updates_profile_and_role(temp_router_config: Path):
@ -147,13 +147,13 @@ def test_action_executor_set_model_updates_profile_and_role(temp_router_config:
res = executor.execute("set_model", { res = executor.execute("set_model", {
"profile_id": "ag-w1", "profile_id": "ag-w1",
"model": "gemini-3.1-pro", "model": "gemini-3.1-pro",
"role_id": "coder-primary", "role_id": "developer-1",
}) })
assert res["ok"] is True assert res["ok"] is True
reloaded = load_router_config() reloaded = load_router_config()
assert reloaded.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro" assert reloaded.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro"
assert reloaded.roles["coder-primary"].default_model == "gemini-3.1-pro" assert reloaded.roles["developer-1"].default_model == "gemini-3.1-pro"
def test_web_client_7_views_exact_order_and_no_redundant_views(): def test_web_client_7_views_exact_order_and_no_redundant_views():

View file

@ -177,16 +177,16 @@ def test_route_and_agent_delta_events():
bus.subscribe("AGENT_UPDATED", _on_agent) bus.subscribe("AGENT_UPDATED", _on_agent)
try: try:
store.apply_delta_route_changed("coder-primary", "ag-w1", failover_reason="Testing failover") store.apply_delta_route_changed("developer-1", "ag-w1", failover_reason="Testing failover")
assert len(route_events) >= 1 assert len(route_events) >= 1
assert route_events[-1]["role_id"] == "coder-primary" assert route_events[-1]["role_id"] == "developer-1"
assert route_events[-1]["failover_reason"] == "Testing failover" assert route_events[-1]["failover_reason"] == "Testing failover"
assert "generation" in route_events[-1] assert "generation" in route_events[-1]
assert "seq" in route_events[-1] assert "seq" in route_events[-1]
assert len(agent_events) >= 1 assert len(agent_events) >= 1
assert agent_events[-1]["role_id"] == "coder-primary" assert agent_events[-1]["role_id"] == "developer-1"
assert agent_events[-1]["agent"].role_id == "coder-primary" assert agent_events[-1]["agent"].role_id == "developer-1"
finally: finally:
bus.unsubscribe(EVENT_ROUTING_UPDATED, _on_route) bus.unsubscribe(EVENT_ROUTING_UPDATED, _on_route)
bus.unsubscribe("AGENT_UPDATED", _on_agent) bus.unsubscribe("AGENT_UPDATED", _on_agent)

View file

@ -159,7 +159,7 @@ def test_single_scheduler_refresh_waits_for_quota_before_delta(monkeypatch: pyte
def test_model_score_rejects_exhausted_pool_and_prefers_more_quota() -> None: def test_model_score_rejects_exhausted_pool_and_prefers_more_quota() -> None:
registry = ModelRegistry.get() registry = ModelRegistry.get()
descriptor = registry.get_model("gemini-2.5-pro") descriptor = registry.get_model("gemini-2.5-pro")
requirements = registry.get_role_requirements("research") requirements = registry.get_role_requirements("researcher")
assert descriptor is not None assert descriptor is not None
ok_empty, _, reason = registry.evaluate_model_score( ok_empty, _, reason = registry.evaluate_model_score(

View file

@ -1,15 +1,14 @@
"""End-to-end verification for Grok and Claude connection, assignment, and testing.""" """End-to-end verification for Grok and Claude connection, assignment, and testing."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.action_handler import do_test_profile
from antigravity_provider.router import action_handler from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
from antigravity_provider.router.hermes_hub_app import do_test_profile
@pytest.mark.unit @pytest.mark.unit
@ -34,12 +33,11 @@ def test_grok_wizard_definition_and_routing_flow():
"""Verify Grok profile definition and routing pipeline addition.""" """Verify Grok profile definition and routing pipeline addition."""
config = RouterConfig( config = RouterConfig(
profiles={}, profiles={},
roles={"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=[])}, roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
) )
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1") ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
assert ok_def, f"Definition failed: {msg_def}" assert ok_def, f"Definition failed: {msg_def}"
assert "grok-worker-1" in config.profiles assert "grok-worker-1" in config.profiles
@ -47,7 +45,7 @@ def test_grok_wizard_definition_and_routing_flow():
ok_route, msg_route = ensure_profile_in_routing("grok-worker-1") ok_route, msg_route = ensure_profile_in_routing("grok-worker-1")
assert ok_route, f"Routing failed: {msg_route}" assert ok_route, f"Routing failed: {msg_route}"
assert "grok-worker-1" in config.roles["coder-primary"].preferred_chain assert "grok-worker-1" in config.roles["developer-1"].preferred_chain
@pytest.mark.unit @pytest.mark.unit
@ -55,12 +53,11 @@ def test_claude_wizard_definition_and_routing_flow():
"""Verify Claude profile definition and routing pipeline addition.""" """Verify Claude profile definition and routing pipeline addition."""
config = RouterConfig( config = RouterConfig(
profiles={}, profiles={},
roles={"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=[])}, roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
) )
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \ with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \ patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch") ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
assert ok_def, f"Definition failed: {msg_def}" assert ok_def, f"Definition failed: {msg_def}"
assert "claude-orch" in config.profiles assert "claude-orch" in config.profiles
@ -68,7 +65,7 @@ def test_claude_wizard_definition_and_routing_flow():
ok_route, msg_route = ensure_profile_in_routing("claude-orch") ok_route, msg_route = ensure_profile_in_routing("claude-orch")
assert ok_route, f"Routing failed: {msg_route}" assert ok_route, f"Routing failed: {msg_route}"
assert "claude-orch" in config.roles["orchestrator"].preferred_chain assert "claude-orch" in config.roles["manager"].preferred_chain
@pytest.mark.unit @pytest.mark.unit
@ -80,7 +77,7 @@ def test_do_test_profile_for_grok_and_claude():
"claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]), "claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]),
} }
) )
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.health_check.return_value = True mock_adapter.health_check.return_value = True
@ -88,7 +85,7 @@ def test_do_test_profile_for_grok_and_claude():
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \ patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter): patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
res_grok = do_test_profile("grok", "grok-worker-1") res_grok = do_test_profile("grok", "grok-worker-1")
assert res_grok["success"] is True assert res_grok["success"] is True
assert res_grok["model"] == "grok-beta" assert res_grok["model"] == "grok-beta"

View file

@ -43,8 +43,8 @@ def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> Profi
account_identity="user@example.test", account_identity="user@example.test",
provider="antigravity", provider="antigravity",
provider_display_name="Google Antigravity", provider_display_name="Google Antigravity",
assigned_roles=["coder-primary"], assigned_roles=["developer-1"],
primary_role="coder-primary", primary_role="developer-1",
is_main_account=True, is_main_account=True,
is_main_orchestrator=False, is_main_orchestrator=False,
auth_state="AUTHENTICATED", auth_state="AUTHENTICATED",
@ -143,7 +143,7 @@ def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) ->
@pytest.mark.ui @pytest.mark.ui
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None: def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
agent = AgentViewModel( agent = AgentViewModel(
role_id="coder-primary", role_id="developer-1",
role_name_ru="Кодер 1", role_name_ru="Кодер 1",
role_description_ru="Основной кодер", role_description_ru="Основной кодер",
assigned_profile_id="account-2", assigned_profile_id="account-2",
@ -163,7 +163,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
) )
team_card = AgentCardWidget(ui_root) team_card = AgentCardWidget(ui_root)
pipeline = RolePipeline( pipeline = RolePipeline(
role_id="coder-primary", role_id="developer-1",
role_name_ru="Кодер 1", role_name_ru="Кодер 1",
default_model="gpt-5", default_model="gpt-5",
max_failover=2, max_failover=2,
@ -214,7 +214,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None: def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
agent = AgentViewModel( agent = AgentViewModel(
role_id="coder-primary", role_id="developer-1",
role_name_ru="Кодер 1", role_name_ru="Кодер 1",
role_description_ru="Основной кодер", role_description_ru="Основной кодер",
assigned_profile_id="ag-w1", assigned_profile_id="ag-w1",

View file

@ -130,7 +130,7 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
@pytest.mark.ui @pytest.mark.ui
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None: def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
policy = SimpleNamespace(preferred_chain=[]) policy = SimpleNamespace(preferred_chain=[])
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy})) monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"manager": policy}))
monkeypatch.setattr( monkeypatch.setattr(
app_module.AutoAssigner, app_module.AutoAssigner,
"assign_profile_to_role", "assign_profile_to_role",

View file

@ -37,8 +37,8 @@ def _profile(index: int) -> ProfileViewModel:
account_identity=f"user-{index}@example.test", account_identity=f"user-{index}@example.test",
provider="antigravity", provider="antigravity",
provider_display_name="Google Antigravity", provider_display_name="Google Antigravity",
assigned_roles=["coder" if index else "orchestrator"], assigned_roles=["coder" if index else "manager"],
primary_role="coder" if index else "orchestrator", primary_role="coder" if index else "manager",
is_main_account=index == 0, is_main_account=index == 0,
is_main_orchestrator=index == 0, is_main_orchestrator=index == 0,
auth_state="AUTHENTICATED", auth_state="AUTHENTICATED",
@ -117,6 +117,7 @@ def test_identity_priority_and_plan_badge_suppression() -> None:
@pytest.mark.ui @pytest.mark.ui
@pytest.mark.timeout(90)
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None: def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
view = AccountsView(ui_root) view = AccountsView(ui_root)
try: try:

View file

@ -29,12 +29,12 @@ def _config():
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]), "coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
} }
roles = { roles = {
"orchestrator": SimpleNamespace(preferred_chain=["orch"]), "manager": SimpleNamespace(preferred_chain=["orch"]),
"coder-primary": SimpleNamespace(preferred_chain=["coder"]), "developer-1": SimpleNamespace(preferred_chain=["coder"]),
"coder-secondary": SimpleNamespace(preferred_chain=["coder"]), "developer-2": SimpleNamespace(preferred_chain=["coder"]),
"reviewer": SimpleNamespace(preferred_chain=["coder"]), "code-reviewer": SimpleNamespace(preferred_chain=["coder"]),
"research": SimpleNamespace(preferred_chain=["coder"]), "researcher": SimpleNamespace(preferred_chain=["coder"]),
"fast": SimpleNamespace(preferred_chain=["coder"]), "tester": SimpleNamespace(preferred_chain=["coder"]),
} }
return SimpleNamespace(roles=roles, profiles=profiles) return SimpleNamespace(roles=roles, profiles=profiles)
@ -83,16 +83,16 @@ def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
def test_validation_finds_cycle_unreachable_and_missing_profile(): def test_validation_finds_cycle_unreachable_and_missing_profile():
config = _config() config = _config()
config.roles["reviewer"].preferred_chain = ["ghost"] config.roles["code-reviewer"].preferred_chain = ["ghost"]
graph = RoutingGraph( graph = RoutingGraph(
nodes=[ nodes=[
GraphNode("orchestrator", 0, 0), GraphNode("manager", 0, 0),
GraphNode("coder-primary", 1, 0), GraphNode("developer-1", 1, 0),
GraphNode("reviewer", 2, 0), GraphNode("code-reviewer", 2, 0),
], ],
edges=[ edges=[
GraphEdge("orchestrator", "coder-primary"), GraphEdge("manager", "developer-1"),
GraphEdge("coder-primary", "orchestrator"), GraphEdge("developer-1", "manager"),
], ],
) )
codes = {issue.code for issue in validate_graph(graph, config)} codes = {issue.code for issue in validate_graph(graph, config)}
@ -108,16 +108,16 @@ def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
) )
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch") ok, _message = controller.add_edge("manager", "developer-1", "FALLBACK", "orch")
assert ok assert ok
assert calls == [("orch", "coder-primary", False)] assert calls == [("orch", "developer-1", False)]
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path): def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
monkeypatch.setattr(graph_module, "load_router_config", _config) monkeypatch.setattr(graph_module, "load_router_config", _config)
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json")) controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
original = controller.graph.nodes[0].x original = controller.graph.nodes[0].x
controller.move_node("orchestrator", original + 100, 50) controller.move_node("manager", original + 100, 50)
assert controller.dirty assert controller.dirty
assert controller.undo() assert controller.undo()
assert controller.graph.nodes[0].x == original assert controller.graph.nodes[0].x == original
@ -133,18 +133,20 @@ def test_graph_store_handles_twenty_nodes(tmp_path):
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch): def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
from antigravity_provider.router import auto_assigner as assigner_module
config = _config() config = _config()
calls = [] calls = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config) monkeypatch.setattr(assigner_module, "load_router_config", lambda: config)
monkeypatch.setattr( monkeypatch.setattr(
wizard_module.AutoAssigner, assigner_module.AutoAssigner,
"assign_profile_to_role", "assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"), lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
) )
assert wizard_module.ensure_profile_in_routing("orch")[0] assert wizard_module.ensure_profile_in_routing("orch")[0]
assert calls == [] assert calls == []
monkeypatch.setattr( monkeypatch.setattr(
wizard_module.AutoAssigner, assigner_module.AutoAssigner,
"get_display_name_and_role", "get_display_name_and_role",
lambda _profile: ("Новый кодер", "coder", "fallback"), lambda _profile: ("Новый кодер", "coder", "fallback"),
) )
@ -255,8 +257,8 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch)
config = SimpleNamespace( config = SimpleNamespace(
profiles={key: SimpleNamespace() for key in ("a", "b", "c")}, profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
roles={ roles={
"orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]), "manager": SimpleNamespace(preferred_chain=["a", "b", "c"]),
"reviewer": SimpleNamespace(preferred_chain=["b"]), "code-reviewer": SimpleNamespace(preferred_chain=["b"]),
}, },
) )
calls = [] calls = []
@ -279,11 +281,11 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch)
monkeypatch.setattr(team_module, "load_router_config", lambda: config) monkeypatch.setattr(team_module, "load_router_config", lambda: config)
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign) monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"]) ok, _message = team_module.persist_role_chain("manager", ["c", "a"])
assert ok assert ok
assert config.roles["orchestrator"].preferred_chain == ["c", "a"] assert config.roles["manager"].preferred_chain == ["c", "a"]
assert config.roles["reviewer"].preferred_chain == ["b"] assert config.roles["code-reviewer"].preferred_chain == ["b"]
assert ("b", "spare", False) in calls assert ("b", "spare", False) in calls

View file

@ -96,7 +96,7 @@ def test_main_account_vs_orchestrator_separation():
service.scan_all() service.scan_all()
agents = service.get_agent_view_models() agents = service.get_agent_view_models()
orch_agent = next((a for a in agents if a.role_id == "orchestrator"), None) orch_agent = next((a for a in agents if a.role_id == "manager"), None)
assert orch_agent is not None assert orch_agent is not None
assert orch_agent.is_main_orchestrator is True assert orch_agent.is_main_orchestrator is True

View file

@ -123,14 +123,12 @@ def test_web_client_html_and_js_7_views_parity():
# Routing view elements # Routing view elements
assert "renderRoutingView" in app_js assert "renderRoutingView" in app_js
assert "routing-pipelines-container" in index_html assert "routing-roles-container" in index_html or "routing-pipelines-container" in index_html
assert "handleNodeDragStart" in app_js assert "setupDragAndDrop" in app_js or "handleNodeDragStart" in app_js
assert "handleNodeDrop" in app_js
assert "handleNodeModelChange" in app_js
# Overview view elements # Overview view elements
assert "renderOverviewView" in app_js assert "renderOverviewView" in app_js
assert "overview-route-diagram" in index_html assert "workflow-canvas" in index_html
# Analytics view elements # Analytics view elements
assert "analytics-total-calls" in index_html assert "analytics-total-calls" in index_html

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import json
import pytest
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
save_router_config,
)
from antigravity_provider.router.workflow_service import WorkflowService
@pytest.fixture
def workflow_service(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = RouterConfig(
profiles={
"account-a": RouterProfileConfig(
profile_id="account-a",
provider="openai-codex",
preferred_models=["model-real-from-config"],
)
},
roles={"developer": RolePolicy(role_name="developer", preferred_chain=["account-a"])},
)
save_router_config(config)
return WorkflowService(tmp_path / "workflow_state.json")
def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path):
snapshot = workflow_service.snapshot()
assert "developer" in [agent["id"] for agent in snapshot["agents"]]
agent = next(item for item in snapshot["agents"] if item["id"] == "developer")
assert agent["agent_file"] == "agents/developer.md"
assert agent["agent_file_exists"] is True
assert (tmp_path / "agents" / "developer.md").is_file()
assert agent["execution_config"]["account"] == "account-a"
assert agent["execution_config"]["model"] == "model-real-from-config"
def test_create_update_file_and_restart_persistence(workflow_service, tmp_path):
created = workflow_service.create_agent({
"name": "Security Reviewer",
"role": "security-reviewer",
"account": "account-a",
"model": "model-real-from-config",
"description": "Проверяет безопасность",
})
workflow_service.save_agent_file(created.id, "# Security\n\nOnly measured facts.")
workflow_service.update_agent(created.id, {"timeout": 91, "position": {"x": 33, "y": 44}})
restarted = WorkflowService(tmp_path / "workflow_state.json")
item = next(agent for agent in restarted.snapshot()["agents"] if agent["id"] == created.id)
assert item["timeout"] == 91
assert item["position"] == {"x": 33, "y": 44}
assert restarted.read_agent_file(created.id)["content"].endswith("Only measured facts.")
def test_delete_requires_explicit_confirmation_when_referenced(workflow_service):
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 3,
"edges": [{"id": "review", "source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}],
})
warning = workflow_service.delete_agent("test-reviewer")
assert warning["confirmation_required"] is True
assert warning["consequences"]["workflow_edges"] == ["review"]
result = workflow_service.delete_agent("test-reviewer", force=True)
assert result["deleted"] is True
assert workflow_service.workflow.edges == []
def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
definition = workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 2,
"edges": [
{"source": "developer", "target": "test-reviewer", "condition": "SUCCESS"},
{"source": "test-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
],
})
assert definition.max_iterations == 2
assert definition.edges[1].condition == "REVIEW_FAILED"
payload = json.loads(workflow_service.state_path.read_text(encoding="utf-8"))
assert payload["workflow"]["max_iterations"] == 2
def test_invalid_edge_and_unknown_model_are_rejected(workflow_service):
with pytest.raises(ValueError, match="отсутствующего агента"):
workflow_service.save_workflow({"edges": [{"source": "developer", "target": "missing"}]})
with pytest.raises(ValueError, match="не доступна"):
workflow_service.update_agent("developer", {"account": "account-a", "model": "invented-model"})
def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path):
workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "developer"})
workflow_service._save()
restarted = WorkflowService(tmp_path / "workflow_state.json")
assert restarted.run["status"] == "interrupted"
assert "перезапуском" in restarted.run["error"]
assert restarted.events[-1].type == "WORKFLOW_INTERRUPTED"
def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch):
workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"})
workflow_service.save_workflow({
"start_agent_id": "developer",
"max_iterations": 2,
"edges": [
{"source": "developer", "target": "loop-reviewer", "condition": "SUCCESS"},
{"source": "loop-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
],
})
class FakeEngine:
def reload_config(self):
return None
def route_request(self, request, role=None, session_id=None):
status = "REVIEW_FAILED" if role == "loop-reviewer" else "SUCCESS"
return {
"choices": [{"message": {"content": status}}],
"router_metadata": {
"provider": "measured-provider",
"profile_id": "account-a",
"selection_trace": {"selected_model": "model-real-from-config"},
},
}
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine())
workflow_service.start("Проверить реальный цикл")
thread = workflow_service._thread
assert thread is not None
thread.join(timeout=3)
assert not thread.is_alive(), "Workflow execution thread leaked after join"
assert workflow_service.run["status"] == "failed"
assert workflow_service.run["error"] == "Достигнут предел итераций: 2"
assert any(event.type == "WORKFLOW_MAX_ITERATIONS" for event in workflow_service.events)
def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatch):
provider_text = "Provider Error: authentication token missing for account-a"
class ErrorEngine:
def reload_config(self):
return None
def route_request(self, request, role=None, session_id=None):
return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]}
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine())
workflow_service.workflow.start_agent_id = "developer"
workflow_service.workflow.edges = []
workflow_service.start("Проверить ошибку")
thread = workflow_service._thread
assert thread is not None
thread.join(timeout=3)
assert not thread.is_alive(), "Workflow execution thread leaked after join"
assert workflow_service.run["status"] == "failed"
assert provider_text in workflow_service.run["error"]
assert any(provider_text in (event.error or "") for event in workflow_service.events)
assert workflow_service.snapshot()["agents"][0]["runtime_state"] == "error"