feat: Supervisor Agent — мониторинг и авто-переключение профилей

This commit is contained in:
ochenstarik-ui 2026-07-20 18:58:12 +07:00
parent 3f9a925f24
commit f97afdc399
2 changed files with 459 additions and 0 deletions

179
docs/adapters/SUPERVISOR.md Normal file
View file

@ -0,0 +1,179 @@
# Supervisor Agent — надзиратель провайдеров
**Версия:** 0.1.0-draft | **Статус:** Draft
## Назначение
Supervisor Agent — специализированный агент-надзиратель, который:
1. Мониторит здоровье всех провайдеров/моделей
2. Обнаруживает исчерпание квот (429, 402, 403)
3. Переключает worker-профили на fallback-модели
4. Возвращает профили на основные модели после восстановления квот
5. Ведёт лог всех инцидентов и переключений
## Архитектура
```
Supervisor Agent (cron: каждые 5 мин)
├─ check_provider(provider) → status
│ ├─ HTTP health probe
│ ├─ quota check (usage API)
│ └─ latency check
├─ detect_incident(status) → action
│ ├─ 429 → switch to fallback
│ ├─ 402 → switch to fallback
│ ├─ timeout → mark degraded
│ └─ recovery → switch back to primary
├─ apply_action(profile, new_model)
│ └─ hermes config set model.default <new_model> -p <profile>
└─ log_incident(incident)
└─ supervisor.log + Telegram notification
```
## Модель инцидента
```yaml
incident:
id: uuid
timestamp: ISO8601
provider: opencode-go|grok-cli|gemini|nvidia|ollama
model: kimi-k2.7-code
error_code: 429|402|403|timeout
action: switch_to_fallback|switch_back|alert_only
profile_affected: worker-code|worker-fast|worker-research|worker-review
old_model: opencode-go/kimi-k2.7-code
new_model: grok-cli/grok-4.5
recovery_eta: "2026-07-21T00:00:00Z"
status: active|resolved
```
## Матрица переключений
| Профиль | Primary | Fallback 1 | Fallback 2 | Last Resort |
|---------|---------|------------|------------|-------------|
| worker-code | opencode-go/kimi | grok-4.5 | gemini-3-flash | opencode-go (любая) |
| worker-fast | grok-4.5 | opencode-go/kimi | gemini-3-flash | opencode-go |
| worker-research | gemini-3-flash | opencode-go/kimi | grok-4.5 | opencode-go |
| worker-review | nvidia/deepseek-v4 | opencode-go/kimi | gemini-3-flash | opencode-go |
## Конфигурация supervisor
```yaml
# supervisor.yaml (рядом с Hermes config)
supervisor:
enabled: true
check_interval: 300 # секунд (5 минут)
health_timeout: 15 # секунд на health probe
cooldown_minutes: 30 # не переключать чаще чем раз в 30 мин
notify:
telegram: true
log: true
providers:
opencode-go:
health_url: http://localhost:20127/v1/models
quota_reset: "daily" # когда сбрасывается квота
grok-cli:
health_url: http://localhost:20127/v1/models
quota_reset: "monthly"
gemini:
health_url: http://localhost:20127/v1/models
quota_reset: "daily"
nvidia:
health_url: http://localhost:20127/v1/models
quota_reset: "daily"
fallback_matrix:
worker-code:
primary: opencode-go/kimi-k2.7-code
fallbacks:
- grok-cli/grok-4.5
- gemini/gemini-3-flash-preview
worker-fast:
primary: grok-cli/grok-4.5
fallbacks:
- opencode-go/kimi-k2.7-code
- gemini/gemini-3-flash-preview
worker-research:
primary: gemini/gemini-3-flash-preview
fallbacks:
- opencode-go/kimi-k2.7-code
worker-review:
primary: nvidia/deepseek-ai/deepseek-v4-pro
fallbacks:
- opencode-go/kimi-k2.7-code
```
## Алгоритм работы
```
1. КАЖДЫЕ check_interval секунд:
a. Для каждого провайдера: POST health_url → статус
b. Если 429/402/403 → INCIDENT
c. Если timeout × 3 → DEGRADED
2. ПРИ ИНЦИДЕНТЕ:
a. Проверить cooldown — если последнее переключение < 30 мин назад alert_only
b. Найти все профили с этим провайдером как primary
c. Для каждого: переключить на первый доступный fallback
d. Записать incident в лог
e. Уведомить в Telegram
3. ПРИ ВОССТАНОВЛЕНИИ:
a. Проверить primary провайдер 3 раза с интервалом 60с
b. Если все 3 проверки OK → вернуть профили на primary
c. Обновить incident.status = resolved
```
## Реализация
### Как cron-задача Hermes
```bash
hermes cron create "*/5 * * * *" \
--name "supervisor-health-check" \
--script scripts/supervisor.py \
--no_agent # script-only, не тратит токены LLM
```
### supervisor.py (псевдокод)
```python
def check_provider(health_url, api_key):
r = requests.post(health_url, headers={"Authorization": f"Bearer {api_key}"},
json={"model": "test", "messages": [{"role":"user","content":"Hi"}], "max_tokens":1})
if r.status_code == 429:
return "quota_exceeded"
if r.status_code == 402:
return "payment_required"
if r.status_code != 200:
return "error"
return "ok"
def switch_profile(profile, model):
subprocess.run(["hermes", "config", "set", "model.default", model, "-p", profile])
def main():
for provider in config["providers"]:
status = check_provider(provider["health_url"], API_KEY)
if status != "ok":
# Найти затронутые профили и переключить
for profile, fb in config["fallback_matrix"].items():
if fb["primary"].startswith(provider):
for fallback_model in fb["fallbacks"]:
if check_provider(...) == "ok":
switch_profile(profile, fallback_model)
notify_telegram(f"{profile}: {fb['primary']} → {fallback_model}")
break
```
## Интеграция с Agent Control Center
Supervisor Agent — это Connector типа `hermes-cron` с адаптером `supervisor`.
В терминах ACC:
- **Agent:** supervisor (runtime: hermes-cron, model: none — script-only)
- **Capabilities:** provider_health_check, profile_switch, incident_logging
- **Connector:** локальный (встроен в Hermes)
- **Approvals:** не требуются (переключение модели — низкий риск)

280
scripts/supervisor.py Normal file
View file

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Supervisor Agent мониторинг и авто-переключение worker-профилей при отказе провайдеров.
Запуск:
python scripts/supervisor.py
или как cron: hermes cron create "*/5 * * * *" --script scripts/supervisor.py --no_agent
Конфигурация: supervisor.yaml (рядом с Hermes config)
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
CONFIG_PATH = HERMES_HOME / "supervisor.yaml"
INCIDENT_LOG = HERMES_HOME / "logs" / "supervisor.log"
STATE_FILE = HERMES_HOME / "cache" / "supervisor_state.json"
# ── Helpers ──────────────────────────────────────────────
def log(msg: str) -> None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}"
print(line, file=sys.stderr)
INCIDENT_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(INCIDENT_LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {"incidents": [], "last_switch": {}, "last_check": {}}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2, default=str))
def send_telegram(msg: str) -> None:
"""Отправить уведомление через hermes send (если настроен TELEGRAM_HOME_CHANNEL)"""
try:
subprocess.run(
["hermes", "send", "-t", "telegram", msg],
capture_output=True, timeout=10
)
except Exception:
pass # telegram не настроен — не критично
# ── Config ───────────────────────────────────────────────
DEFAULT_CONFIG = {
"check_interval": 300,
"health_timeout": 15,
"cooldown_minutes": 30,
"api_key": "sk-a345af809e8a26f0693b9405344edc8adc5b5a96", # 9Router key
"health_url": "http://localhost:20127/v1/chat/completions",
"notify_telegram": True,
"providers": {
"opencode-go": {"test_model": "opencode-go/kimi-k2.7-code"},
"grok-cli": {"test_model": "grok-cli/grok-4.5"},
"gemini": {"test_model": "gemini/gemini-3-flash-preview"},
"nvidia": {"test_model": "nvidia/deepseek-ai/deepseek-v4-pro"},
},
"fallback_matrix": {
"worker-code": {
"primary": "opencode-go/kimi-k2.7-code",
"fallbacks": [
"grok-cli/grok-4.5",
"gemini/gemini-3-flash-preview",
],
},
"worker-fast": {
"primary": "grok-cli/grok-4.5",
"fallbacks": [
"opencode-go/kimi-k2.7-code",
"gemini/gemini-3-flash-preview",
],
},
"worker-research": {
"primary": "gemini/gemini-3-flash-preview",
"fallbacks": [
"opencode-go/kimi-k2.7-code",
],
},
"worker-review": {
"primary": "nvidia/deepseek-ai/deepseek-v4-pro",
"fallbacks": [
"opencode-go/kimi-k2.7-code",
],
},
},
}
def load_config() -> dict:
if CONFIG_PATH.exists():
import yaml
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
return DEFAULT_CONFIG
# ── Health Check ─────────────────────────────────────────
def check_provider(provider_id: str, test_model: str, config: dict) -> str:
"""Проверяет провайдер через 9Router health probe.
Возвращает: ok, quota_exceeded, payment_required, error, timeout
"""
body = json.dumps({
"model": test_model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 1,
"stream": False,
}).encode()
req = urllib.request.Request(
config["health_url"],
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {config['api_key']}",
},
)
try:
resp = urllib.request.urlopen(req, timeout=config["health_timeout"])
return "ok"
except urllib.error.HTTPError as e:
if e.code == 429:
return "quota_exceeded"
if e.code == 402:
return "payment_required"
if e.code in (401, 403):
return "auth_error"
return f"http_{e.code}"
except Exception:
return "timeout"
# ── Profile Switching ────────────────────────────────────
def get_current_model(profile: str) -> Optional[str]:
"""Получить текущую модель профиля"""
try:
r = subprocess.run(
["hermes", "config", "get", "model.default", "-p", profile],
capture_output=True, text=True, timeout=10,
)
return r.stdout.strip()
except Exception:
return None
def switch_profile_model(profile: str, model: str) -> bool:
"""Переключить модель профиля"""
try:
r = subprocess.run(
["hermes", "config", "set", "model.default", model, "-p", profile],
capture_output=True, text=True, timeout=15,
)
return r.returncode == 0
except Exception as e:
log(f" ERROR switching {profile}: {e}")
return False
# ── Main ─────────────────────────────────────────────────
def main():
config = load_config()
state = load_state()
now = datetime.now()
if not config.get("enabled", True):
return
log("=== Supervisor check ===")
# 1. Проверить всех провайдеров
provider_status = {}
for provider_id, pconfig in config["providers"].items():
status = check_provider(provider_id, pconfig["test_model"], config)
provider_status[provider_id] = status
icon = "" if status == "ok" else ""
log(f" {icon} {provider_id}: {status}")
# 2. Найти профили с упавшими провайдерами
for profile_name, matrix in config["fallback_matrix"].items():
primary_model = matrix["primary"]
primary_provider = primary_model.split("/")[0]
if provider_status.get(primary_provider, "ok") == "ok":
continue # провайдер жив
# Проверить cooldown
last_switch_ts = state["last_switch"].get(profile_name, 0)
cooldown = config["cooldown_minutes"] * 60
if isinstance(last_switch_ts, str):
last_switch_ts = datetime.fromisoformat(last_switch_ts).timestamp()
if now.timestamp() - last_switch_ts < cooldown:
log(f"{profile_name}: в cooldown, пропускаю")
continue
# Искать рабочий fallback
current_model = get_current_model(profile_name)
log(f"{profile_name}: primary {primary_model} DOWN (current: {current_model})")
for fallback_model in matrix["fallbacks"]:
fb_provider = fallback_model.split("/")[0]
if provider_status.get(fb_provider, "ok") == "ok":
if current_model == fallback_model:
log(f" ✓ уже на fallback {fallback_model}")
break
if switch_profile_model(profile_name, fallback_model):
incident = {
"timestamp": now.isoformat(),
"profile": profile_name,
"from_model": primary_model,
"to_model": fallback_model,
"reason": provider_status[primary_provider],
}
state["incidents"].append(incident)
state["last_switch"][profile_name] = now.isoformat()
save_state(state)
msg = (
f"🔴 Supervisor: {profile_name}\n"
f" {primary_model}{fallback_model}\n"
f" Причина: {provider_status[primary_provider]}"
)
log(f" ✓ SWITCHED: {primary_model}{fallback_model}")
if config.get("notify_telegram"):
send_telegram(msg)
break
else:
log(f" ❌ Нет доступных fallback для {profile_name}!")
# 3. Проверить восстановление — если primary снова жив, вернуть
for profile_name, matrix in config["fallback_matrix"].items():
primary_model = matrix["primary"]
primary_provider = primary_model.split("/")[0]
current_model = get_current_model(profile_name)
if current_model and current_model != primary_model and provider_status.get(primary_provider) == "ok":
# Primary восстановился — проверить 3 раза
ok_count = 0
for _ in range(3):
if check_provider(primary_provider, config["providers"][primary_provider]["test_model"], config) == "ok":
ok_count += 1
time.sleep(2)
if ok_count == 3:
if switch_profile_model(profile_name, primary_model):
incident = {
"timestamp": now.isoformat(),
"profile": profile_name,
"from_model": current_model,
"to_model": primary_model,
"reason": "recovery",
}
state["incidents"].append(incident)
save_state(state)
msg = (
f"🟢 Supervisor: {profile_name}\n"
f" {current_model}{primary_model}\n"
f" Причина: primary восстановлен"
)
log(f" ✓ RECOVERED: {current_model}{primary_model}")
if config.get("notify_telegram"):
send_telegram(msg)
log("=== Check complete ===\n")
if __name__ == "__main__":
main()