Compare commits

..

No commits in common. "88bb2c0a954bffbaf304fee97c3b506d1a87195b" and "b2391c2b5b6cf33711088b79eaa6bb4f7de66901" have entirely different histories.

11 changed files with 8 additions and 895 deletions

View file

@ -332,43 +332,6 @@ event store через `run_projections()` — консистентно с sched
`/etc/simbiot/dashboard.env` (env-файл юнита, режим 600); без токена — 401. `/etc/simbiot/dashboard.env` (env-файл юнита, режим 600); без токена — 401.
UFW: порт 8765/tcp открыт (токен-защищён). UFW: порт 8765/tcp открыт (токен-защищён).
## Decision-модель (Ollama /v1/systemone: облачная clef:27b → локальная nimble)
Decision-модели из бенча `symbiot/systemone-bench`. Основная — `clef:27b`
в Ollama Cloud (`https://ollama.com/v1/systemone`, ключ
`SIMBIOT_DECISION_API_KEY` в `.env` инстанса, ~0.6–0.9 с на запрос).
Запасная — локальная nimble (qwen3.5-9B Q8_0, `ollama pull nimble`,
Ollama ≥ 0.35.1, CPU 10–30 с): отвечает, если облако упало/квота/сеть.
У каждого бэкенда свой предохранитель (5 мин после сбоя). Не генерирует
текст — возвращает распределение по вариантам (`choice`) или P(да)
(`noul`); детерминирована, кредитов роутера не тратит. Схема как в
`ga/router42.py` бенча: детерминированная механика — быстрый путь,
nimble — арбитр там, где механика слепа; недоступна — тихий фолбэк.
| Потребитель | Было | С моделью | Режим по умолчанию |
|---|---|---|---|
| `tasks` — порядок батча тика | чистый FIFO | из 9 старейших первой — самая ценная для AUM, остальные FIFO | on |
| `topic` — тема инициативной реплики | round-robin | выбор из 9 наименее использованных с учётом доски | on |
| `gate` — серая зона гейта (только warn/info) | LLM-тик всегда | P(нужен LLM) < 0.35 → тихий такт | **shadow** (только пишет) |
| `agent` — `cli.py decide -q … -o k=v` | — | выбор 2–9 вариантов для LLM-агента тика | on |
Конфиг (env > `.env` инстанса): `SIMBIOT_DECISION=off|shadow|on` (дефолт
off — тесты герметичны), точечно `SIMBIOT_DECISION_<TASKS|TOPIC|GATE>`,
`SIMBIOT_DECISION_API_KEY` (есть → основной = облако clef:27b, нет →
локальная nimble без запасного), `SIMBIOT_DECISION_URL`,
`SIMBIOT_DECISION_MODEL`, `SIMBIOT_DECISION_TIMEOUT` (облако 15 с /
локально 90 с), `SIMBIOT_DECISION_FALLBACK=on|off`,
`SIMBIOT_DECISION_FALLBACK_URL|MODEL|TIMEOUT` (локальная nimble, 90 с), `SIMBIOT_DECISION_GATE_MIN_P` (0.35),
`SIMBIOT_DECISION_KEEP_ALIVE` (2h). Глобальный shadow — потолок для всех.
Ограничения из бенча: шортлист строго ≤ 9 (точность 100% на 6 → 50% на
10), `confidence` — не вероятность правильности. Кеш по хешу
запроса (ответы детерминированы — та же очередь между тиками бесплатна),
только min/hour/day (не sec). Каждый реальный вызов → событие
`DecisionMade` (с `backend`/`model`) (policy trace для GA-сшивки с
исходом). Статус/статистика: `cli.py decide`, `cli.py decide --stats`.
Тест: `./venv/bin/python tests/test_decision.py` (фейковый эндпоинт).
## Модели ## Модели
9router (router.cil.by), отдельный ключ `sk-simbiot` в `.env`, комбо `auto`: 9router (router.cil.by), отдельный ключ `sk-simbiot` в `.env`, комбо `auto`:

77
cli.py
View file

@ -190,14 +190,6 @@ def main():
p_mcs = sub.add_parser("mcp-stats", help="статистика MCP-вызовов по журналу mcp_calls.log (t-261015091108)") p_mcs = sub.add_parser("mcp-stats", help="статистика MCP-вызовов по журналу mcp_calls.log (t-261015091108)")
p_mcs.add_argument("--tail", type=int, default=10, help="последние N вызовов (0 — только сводка)") p_mcs.add_argument("--tail", type=int, default=10, help="последние N вызовов (0 — только сводка)")
p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика")
p_dec.add_argument("-q", "--question", help="вопрос/инструкция (без -o — noul: P(да))")
p_dec.add_argument("-o", "--option", action="append", default=[],
help="вариант ключ=описание (2–9 штук → choice)")
p_dec.add_argument("-s", "--state", default="", help="ситуация/контекст для модели")
p_dec.add_argument("--stats", type=int, nargs="?", const=200, metavar="N",
help="сводка последних N событий DecisionMade")
args = ap.parse_args() args = ap.parse_args()
if args.cmd == "init": if args.cmd == "init":
@ -594,75 +586,6 @@ def main():
es.init_db() es.init_db()
for t in commands.list_tasks(): for t in commands.list_tasks():
print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}")
elif args.cmd == "decide":
from harness import decision
if args.stats:
import collections
es.init_db()
total = es.count_events()
evs = [e for e in es.get_events(max(0, total - 20000), 20000)
if e["event_type"] == "DecisionMade"][-args.stats:]
by = collections.defaultdict(lambda: {"n": 0, "applied": 0, "err": 0, "ms": []})
for e in evs:
d = json.loads(e["data"])
b = by[d.get("purpose", "?")]
b["n"] += 1
b["applied"] += bool(d.get("applied"))
b["err"] += bool(d.get("error"))
if d.get("ms") and not d.get("error"):
b["ms"].append(d["ms"])
if not by:
print("(решений ещё не было)")
for p, b in sorted(by.items()):
ms = sorted(b["ms"])
med = ms[len(ms) // 2] if ms else 0
print(f"{p:8} решений {b['n']:4} применено {b['applied']:4} "
f"ошибок {b['err']:3} медиана {med} мс")
return
if args.question:
opts = {}
for o in args.option:
k, _, v = o.partition("=")
opts[k.strip()] = v.strip() or k.strip()
os.environ.setdefault("SIMBIOT_DECISION_AGENT", "on")
if decision.mode("agent") == "off":
print("decision-модель выключена (SIMBIOT_DECISION=off) — включи on/shadow")
sys.exit(2)
if len(opts) > decision.MAX_OPTIONS:
print(f"ОШИБКА: вариантов {len(opts)} > {decision.MAX_OPTIONS} — "
"сократи шортлист (точность падает до 50% на 10)")
sys.exit(2)
state = args.state or decision.AUM_GOAL
if opts:
r = decision.choose("agent", opts, state, args.question)
if not r:
print("нет решения (модель недоступна или ответ вне вариантов)")
sys.exit(1)
print(f"ВЫБОР: {r['choice']}")
for k, v in sorted(r["probabilities"].items(), key=lambda kv: -kv[1]):
print(f" {v:6.1%} {k}")
else:
r = decision.yes_prob("agent", args.question, state)
if not r:
print("нет решения (модель недоступна)")
sys.exit(1)
print(f"P(да) = {r['p']:.1%}")
return
st = decision.status()
print(f"режим: {st['mode']} ({', '.join(f'{k}={v}' for k, v in st['modes'].items())})")
for b in st["backends"]:
where = "облако" if b["cloud"] else "локально"
print(f"{b['name']}: {b['model']} ({where}) {b['url']}")
if not b.get("reachable"):
print(f" НЕДОСТУПЕН — {b.get('error')}")
continue
line = f" доступен; модель {'есть' if b.get('installed') else 'НЕ НАЙДЕНА'}"
if "loaded" in b:
line += f"; в памяти: {'да' if b['loaded'] else 'нет (холодный старт ~20 с)'}"
if b["breaker"].get("until", 0) > __import__("time").time():
line += f"; ПРЕДОХРАНИТЕЛЬ: {b['breaker'].get('error')}"
print(line)
elif args.cmd == "events": elif args.cmd == "events":
es.init_db() es.init_db()
if args.after is None: if args.after is None:

View file

@ -19,10 +19,6 @@ Push теперь идёт через admin-токен 9f29709… (значен
Диагностика: `scripts/deploy_channel_probe.py`. Правка: менять только pushurl, Диагностика: `scripts/deploy_channel_probe.py`. Правка: менять только pushurl,
fetch остаётся на узком токене. Деплой лендинга: docs/DEPLOY-LANDING.md. fetch остаётся на узком токене. Деплой лендинга: docs/DEPLOY-LANDING.md.
Ollama Cloud для decision-модели харнеса (clef:27b, /v1/systemone):
`SIMBIOT_DECISION_API_KEY` в `/var/lib/simbiot-harness/env/.env` (600),
подхватывается `harness/decision.py`; бэкап до правки — `.env.pre-decision`.
## 2. Наследие агента Гермес (живое, сервисы active) ## 2. Наследие агента Гермес (живое, сервисы active)
| Путь | Что | Права | Примечание | | Путь | Что | Права | Примечание |

View file

@ -1,63 +0,0 @@
# Дайджест hour 2026-10-16 ~16:00 UTC (тик hour-497824)
## Выжимка часа (15:00→16:05 UTC)
- **Батч из 4 задач закрыт полностью (S/M/L, все complete с доставкой в TG).**
Главная тема часа — инфраструктура Claude-панелей агентов: перенос Mon на свой
сервер, два фикса запуска claude, верификация патча флага.
### t-261016152313 [L] — Mon на своём сервере ✅
- Панель mon.ui.shkor.com больше НЕ живёт на сервере Alex: развёрнута на
собственном сервере агента Mon **hermes-mon (94.130.177.31, Hetzner cx33)**.
- Сделано: OS-юзер mon (sudo), Node 22.23.3 + claude-code 2.1.301 + cloudcli
1.37.3 (npm -g), юнит cloudcli-ui.service (User=mon, порт 3101, IS_SANDBOX=1),
caddy-свитч 127.0.0.1:3201 → 94.130.177.31:3101.
- Проверено: https://mon.ui.shkor.com → 200, логин mon/MonCoopbc8becf2 (учётка
уникальная, single-user, bcrypt) → success+JWT через публичный домен. Креды
отправлены Алексу идемпотентно (ключ mon-panel-creds-261016).
- AUM-эффект: изоляция агента (стандарт «1 агент = 1 сервер»), разгрузка общего
хоста, закрытие вектора доступа наружных агентов к общему /home.
### t-261016153333 [M] — claude not found на upnfame ✅
- Причина оказалась не PATH, а отсутствие пакета: на hermes-upnfame-8733
(49.13.157.28) стоял только cloudcli. `npm i -g @anthropic-ai/claude-code` →
claude 2.1.301 в /usr/bin (в PATH юнита). Проверено от юзера upnfame, сервис
перезапущен (active, HTTP 200).
### t-261016153621 [M] — IS_SANDBOX=1 для claude под root ✅
- Механика подтверждена по коду: pty.spawn наследует env сервиса
(shell-websocket.service.js:510 `...process.env`). В юнит cloudcli-ui.service
(порт 3201) добавлен `Environment=IS_SANDBOX=1` (бэкап .bak-261016-1600),
daemon-reload + restart: в /proc/<PID>/environ переменная есть, панели живы
(simbiot.ui 200, gennady.ui 200). Тот же патч вшит в юнит Mon.
- Остаточное (вне скоупа): под root claude пишет «Not logged in» — нет OAuth-кредов;
на серверах агентов (lunar, mon) креды живут в ~/.claude юзера. Кандидат в задачу:
единый способ аутентификации claude на панелях (9router env или per-user login).
### t-261016155737 [M] — флаг --dangerously-skip-permissions ✅
- Источник флага найден окончательно и закрыт верификацией на живом коде:
buildShellCommand добавляет флаг ТОЛЬКО при `bypassPermissions=true` от фронта
И `process.getuid() !== 0`. Изолированный прогон реальной функции из файла
сервиса: uid=0 + bypass=true → `claude` (без флага); uid=33 + bypass=true → флаг
есть. Прошлый патч работает; под root флаг прилететь больше не может физически.
Алексу рекомендовано Ctrl+Shift+R; если панель всё ещё отказывает — это уже
«Not logged in», не флаг.
## Состояние лестницы
- sec/min/hour/day — работают; открытых эскалаций: 0 (все 108 исторические acked).
- Ошибки уровней: min 43 / hour 10 / day 1 — исторические таймауты воркеров,
новых за час не зафиксировано. Общий хост: load ~14 в пике сек-уровня —
подтверждает пользу выноса панелей (Mon теперь не на нём).
- Доска: 1 новая ready-задача (Показ MCP-токена на деталях агента в кабинете
cil.by) — возьмётся следующим часовым тиком по очереди.
## AUM
- AUM = 531.44 у.е. (метрика balances.json; вниз от 731.44 — считается от
burn-динамики 9router: 24ч=54.0, 7д=254.99). Burn 7д остаётся главным минусом;
ревизия spend по уровням — по-прежнему кандидат в задачи дня.
- Вклад часа в долгосрочную AUM: +1 агент на изолированном сервере (Mon),
работоспособные claude-панели у 3 агентов (simbiot/upnfame/mon), найден
реальный источник флага — меньше повторных поломок в онбординге следующих
агентов стада.

View file

@ -1,44 +0,0 @@
# Дайджест hour 2026-10-16 ~17:00 UTC
## Выжимка часа (16:00→17:10 UTC)
- **Батч из 2 задач [M] закрыт полностью** (обе complete с доставкой в TG). Главная
тема часа — MCP-токены агентов: показ на портале + тул выдачи + замена временного
токена у mon.
- **Переписка:** Alex спросил «после этого что-то ещё делал?» — ответил выжимкой
прошлого часа (upnfame claude 2.1.301, IS_SANDBOX=1 в юнитах, точный источник
флага --dangerously-skip-permissions; остаточное — Not logged in под root).
Инбокс чист: 0 неотвеченных.
### t-261016160904 [M] — MCP-токен на деталях агента ✅
- cil.by/agent?id=N, секция «Каналы» (03): блок «MCP-токен» — список токенов
(#id, label, last_used_at), кнопка «Выдать новый токен» (существующий POST
/api/agent/<id>/token), копирование, инструкция: claude mcp add simbiot
--transport http https://cil.by/mcp --header 'Authorization: Bearer agt_…'
+ готовый mcp.json для Cursor/OpenCode.
- Верификация: страница 200 с блоком, JS-синтаксис чист, e2e test client
(GET /tokens 200, POST /token 200 agt_…). Бэкап agent_detail.html.bak-261016-mcptoken.
### t-261016162612 [M] — тул выдачи MCP-токенов ✅
- Новый MCP-тул **agent_token_issue** на cil.by/mcp (72 тула в спеке): выдаёт
agt_-токен агенту, revoke=true — ресет прежних. Показ один раз, хранится sha256-хэш.
- Права: владелец — себе; агент председателя (Simbiot→alex) — кому угодно.
Проверено живым вызовом (выдача токенов Cloud CLI и Mon от имени Simbiot через
cli.py mcp-call) и негативным тестом (агент члена → чужому = отказ).
- **Mon получил собственный токен**: на 138.201.156.35 в /home/agent-mon/.claude.json
временный токен Simbiot заменён на agt_IUDNJeub… (label mon-own-mcp-261016),
бэкап .claude.json.bak-261016-mcptoken, whoami через шлюз → agent Mon ✅.
- Рестарт coop-portal.service (active, 200). Бэкап mcp.py.backup-261016-agenttokenissue.
## Состояние лестницы
- sec/min/hour/day — работают; открытых эскалаций: 0.
- Минутка тихая (gate:quiet, LLM не запускался ×6 подряд).
- Доска: обе ready-задачи часа закрыты, новых ready нет.
## AUM
- AUM = 531.44 у.е. (balances.json); burn 9router: 24ч=57.8, 7д=258.8 (вверх с 254.99).
- Вклад часа в долгосрочную AUM: самосервис токенов (не нужен Алекс для выдачи),
чистая атрибуция mon (больше не работает токеном Simbiot — аудит-гигиена стада),
снят блокер подключения MCP-агентов (панель → токен за минуту).

View file

@ -1,391 +0,0 @@
"""Simbiot Harness — decision-модель (Ollama /v1/systemone: облачная clef → локальная nimble).
Схема из systemone-bench (ga/router42.py, последняя версия): детерминированная
механика остаётся быстрым путём, decision-модель — арбитр там, где механика
слепа (FIFO по задачам, round-robin по темам, порог гейта). Недоступна
модель / таймаут / мусор в ответе → None, вызывающий берёт свой фолбэк.
Тик никогда не падает из-за модели.
Контракт эндпоинта (README бенча, пробы 2026-10-04):
- choice: criteria — объект {ключ: описание}, 2–26 опций; ответ — ключ +
probabilities. Точность 100% на шортлисте 6 → 50% на 10, поэтому здесь
жёсткий кап MAX_OPTIONS = 9 (вызывающий режет шортлист сам);
- noul: P(«да») в [0, 1];
- confidence — концентрация распределения, НЕ вероятность правильности.
Бэкенды по порядку (у каждого свой предохранитель):
1) основной — SIMBIOT_DECISION_URL/MODEL/API_KEY. С ключом Ollama Cloud:
https://ollama.com/v1/systemone, clef:27b — ~0.6–0.8 с на запрос;
2) запасной — локальная nimble (SIMBIOT_DECISION_FALLBACK_URL/MODEL,
SIMBIOT_DECISION_FALLBACK=off — выключить): CPU, 10–30 с, холодный
старт ~20 с, зато без сети и квот.
Без API_KEY и URL основной = локальная nimble, запасного нет (как раньше).
Ответы детерминированы (один и тот же запрос → те же вероятности), поэтому
кеш по хешу вопроса+состояния валиден: одинаковая очередь в соседних тиках
не стоит повторного прогона.
Режим — SIMBIOT_DECISION (env процесса сильнее .env инстанса):
off (дефолт) — модель не зовётся; on — решения применяются;
shadow — модель зовётся и решение пишется событием, но не применяется.
Точечный оверрайд потребителя: SIMBIOT_DECISION_<PURPOSE>=off|shadow|on
(например SIMBIOT_DECISION_GATE=on). Гейт по умолчанию — shadow: пропуск
LLM-тика по мнению модели включается только явно.
Каждый реальный вызов пишет DecisionMade (purpose, опции, выбор,
вероятности, латентность, applied) — policy trace для будущей GA-сшивки
с исходом (skills/systemone-bench, §4 «Интеграция в тик харнеса»).
"""
import hashlib
import json
import os
import threading
import time
import urllib.error
import urllib.request
from . import paths as _paths
MAX_OPTIONS = 9 # шортлист stage2 строго ≤ 9 (бенч: 100% на 6 → 50% на 10)
CACHE_TTL = 3600.0 # ответы детерминированы — час держим
CACHE_MAX = 256
BREAKER_COOLDOWN = 300.0 # после сбоя молчим 5 минут, не тормозим тики таймаутами
_LOCK = threading.Lock()
_CACHE = {} # sha256(вопросы+состояние) → (ts, answers, model)
_BREAKERS = {} # имя бэкенда → {"until", "error"}
LOCAL_URL = "http://127.0.0.1:11434/v1/systemone"
CLOUD_URL = "https://ollama.com/v1/systemone"
# тексты ниже уходят в модель — на английском: nimble (qwen3.5) калибровался
# в бенче на английских инструкциях
AUM_GOAL = ("Goal of the harness: maximize long-term AUM of the cooperative "
"(income and resources of all projects over a year+ horizon). "
"Prefer work that adds >=1% to AUM in a year over short wins that "
"eat the long-term base.")
# ────────────────────────── конфиг ──────────────────────────
def _cfg(key: str, default: str = "") -> str:
"""env процесса > .env инстанса (как runtime._env_or_file, без импорта runtime)."""
val = os.environ.get(key, "").strip()
if val:
return val
try:
with open(_paths.env_path(), encoding="utf-8") as f:
for line in f:
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'") or default
except OSError:
pass
return default
def _float(key: str, default: float) -> float:
try:
return float(_cfg(key, str(default)))
except ValueError:
return default
def backends() -> list:
"""[{name, url, model, key, timeout}] в порядке опроса."""
key = _cfg("SIMBIOT_DECISION_API_KEY")
if key:
primary = {"name": "primary", "key": key,
"url": _cfg("SIMBIOT_DECISION_URL", CLOUD_URL),
"model": _cfg("SIMBIOT_DECISION_MODEL", "clef:27b"),
"timeout": _float("SIMBIOT_DECISION_TIMEOUT", 15.0)}
else:
primary = {"name": "primary", "key": "",
"url": _cfg("SIMBIOT_DECISION_URL", LOCAL_URL),
"model": _cfg("SIMBIOT_DECISION_MODEL", "nimble"),
"timeout": _float("SIMBIOT_DECISION_TIMEOUT", 90.0)}
out = [primary]
fb_default = "on" if primary["key"] else "off" # запасной — только за облаком
if _cfg("SIMBIOT_DECISION_FALLBACK", fb_default).lower() == "on":
out.append({"name": "fallback", "key": "",
"url": _cfg("SIMBIOT_DECISION_FALLBACK_URL", LOCAL_URL),
"model": _cfg("SIMBIOT_DECISION_FALLBACK_MODEL", "nimble"),
"timeout": _float("SIMBIOT_DECISION_FALLBACK_TIMEOUT", 90.0)})
return out
def url() -> str:
return backends()[0]["url"]
def model_name() -> str:
return backends()[0]["model"]
_PURPOSE_DEFAULTS = {"gate": "shadow"}
def mode(purpose: str = "") -> str:
"""off | shadow | on для потребителя purpose (глобальный режим — потолок)."""
glob = _cfg("SIMBIOT_DECISION", "off").lower()
if glob not in ("on", "shadow"):
return "off"
if purpose:
local = _cfg("SIMBIOT_DECISION_" + purpose.upper(),
_PURPOSE_DEFAULTS.get(purpose, glob)).lower()
if local not in ("on", "shadow"):
return "off"
if glob == "shadow":
return "shadow"
return local
return glob
# ────────────────────────── транспорт ──────────────────────────
def _post(be: dict, body: dict) -> dict:
raw = json.dumps(body, ensure_ascii=False).encode()
headers = {"Content-Type": "application/json"}
if be["key"]:
headers["Authorization"] = "Bearer " + be["key"]
req = urllib.request.Request(be["url"], data=raw, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=be["timeout"]) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
# тело ошибки systemone информативно («model not found», «not a decision model»)
raise ValueError(f"HTTP {e.code}: {e.read().decode(errors='replace')[:160]}")
def ask(questions: dict, state: str):
"""Сырой вызов: {qid: question} → (answers, meta) | (None, meta).
Бэкенды по порядку (backends()); сбой бэкенда взводит ЕГО предохранитель
на BREAKER_COOLDOWN и сразу пробует следующий. meta: {"cached", "ms",
"error", "model", "backend"}; error — сводка сбоев, если все упали.
"""
state = state[:24000]
key = hashlib.sha256(json.dumps({"s": state, "q": questions}, sort_keys=True,
ensure_ascii=False).encode()).hexdigest()
now = time.time()
with _LOCK:
hit = _CACHE.get(key)
if hit and now - hit[0] < CACHE_TTL:
return hit[1], {"cached": True, "ms": 0, "error": "", "model": hit[2],
"backend": "cache"}
errors = []
t0 = time.time()
for be in backends():
with _LOCK:
br = _BREAKERS.get(be["name"]) or {}
if time.time() < br.get("until", 0.0):
errors.append(f"{be['name']}: breaker ({br.get('error')})")
continue
body = {"model": be["model"], "state": state, "questions": questions}
if not be["key"]:
body["keep_alive"] = _cfg("SIMBIOT_DECISION_KEEP_ALIVE", "2h")
t1 = time.time()
try:
d = _post(be, body)
answers = d.get("answers")
if not isinstance(answers, dict):
raise ValueError(f"нет answers: {str(d)[:120]}")
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as e:
err = f"{e.__class__.__name__}: {str(e)[:160]}"
with _LOCK:
_BREAKERS[be["name"]] = {"until": time.time() + BREAKER_COOLDOWN,
"error": err}
errors.append(f"{be['name']} {be['model']}: {err} "
f"({int((time.time() - t1) * 1000)} мс)")
continue
with _LOCK:
if len(_CACHE) >= CACHE_MAX:
for k in sorted(_CACHE, key=lambda k: _CACHE[k][0])[:CACHE_MAX // 4]:
_CACHE.pop(k, None)
_CACHE[key] = (time.time(), answers, be["model"])
_BREAKERS.pop(be["name"], None)
return answers, {"cached": False, "ms": int((time.time() - t1) * 1000),
"error": "; ".join(errors), "model": be["model"],
"backend": be["name"]}
return None, {"cached": False, "ms": int((time.time() - t0) * 1000),
"error": "; ".join(errors) or "нет бэкендов", "model": None,
"backend": None}
def _record(purpose: str, kind: str, payload: dict, meta: dict, applied: bool):
"""DecisionMade в стор — только на реальный вызов (кеш-хиты не спамят стор)."""
if meta.get("cached"):
return
try:
from . import es
rid = hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False)
.encode()).hexdigest()[:12]
es.append_event("decision", f"{purpose}:{int(time.time())}:{rid}", "DecisionMade", {
"purpose": purpose, "kind": kind, "model": meta.get("model"),
"backend": meta.get("backend"),
"ms": meta.get("ms"), "error": meta.get("error") or None,
"applied": applied, **payload,
})
except Exception:
pass # трасса — best effort, решение важнее записи
# ────────────────────────── примитивы ──────────────────────────
def choose(purpose: str, options: dict, state: str, instructions: str):
"""Выбор одного ключа из options {ключ: описание} (2..MAX_OPTIONS).
Возвращает {"choice", "probabilities", "confidence", "applied"} или None
(режим off, <2 опций, модель недоступна, ответ вне опций). applied=False
в shadow — вызывающий обязан тогда остаться на своём фолбэке.
"""
m = mode(purpose)
if m == "off" or len(options) < 2:
return None
opts = dict(list(options.items())[:MAX_OPTIONS])
crit = {k: (v or k)[:400] for k, v in opts.items()}
answers, meta = ask({"c": {"type": "choice", "instructions": instructions,
"criteria": crit}}, state)
a = (answers or {}).get("c") or {}
pick = a.get("choice")
if pick not in opts:
_record(purpose, "choice", {"options": list(opts), "choice": None}, meta, False)
return None
probs = {k: round(float(v), 4) for k, v in (a.get("probabilities") or {}).items()}
applied = m == "on"
_record(purpose, "choice", {"options": list(opts), "choice": pick,
"probabilities": probs}, meta, applied)
return {"choice": pick, "probabilities": probs,
"confidence": round(float(a.get("confidence") or 0), 4),
"applied": applied}
def yes_prob(purpose: str, question: str, state: str):
"""noul: P(«да») на вопрос. {"p", "applied"} или None."""
m = mode(purpose)
if m == "off":
return None
answers, meta = ask({"n": {"type": "noul", "instructions": question}}, state)
p = ((answers or {}).get("n") or {}).get("noul")
if not isinstance(p, (int, float)):
_record(purpose, "noul", {"question": question[:300], "p": None}, meta, False)
return None
applied = m == "on"
_record(purpose, "noul", {"question": question[:300], "p": round(float(p), 4)},
meta, applied)
return {"p": float(p), "applied": applied}
# ────────────────────────── потребители харнеса ──────────────────────────
def _policy_line(model: dict) -> str:
pol = (model or {}).get("policy") or {}
aum = pol.get("aum_target")
if isinstance(aum, dict):
aum = json.dumps(aum, ensure_ascii=False)
return f"Current AUM policy: {str(aum)[:300]}" if aum else ""
def prioritize_tasks(level_name: str, tasks: list, model: dict):
"""Какая из ready-задач (шортлист ≤ 9, старейшие) даст больше AUM — её первой.
tasks — уже отсортированы FIFO. Возвращает id выбранной или None
(тогда остаётся FIFO). Состояние без времени и счётчиков — чтобы
одинаковая очередь попадала в кеш между тиками.
"""
short = tasks[:MAX_OPTIONS]
if len(short) < 2:
return None
opts = {}
for t in short:
desc = f"[{t.get('size')}] {t.get('title') or ''}"
if t.get("details"):
desc += " — " + t["details"][:120]
opts[t["id"]] = desc
state = "\n".join(x for x in (
AUM_GOAL, _policy_line(model),
f"Harness level '{level_name}' picks the task to execute FIRST this tick. "
"Older tasks are listed first; a task blocking money, clients or "
"other people outweighs internal polish.") if x)
r = choose("tasks", opts, state,
"Which task should be executed first to maximize long-term AUM?")
return r["choice"] if r and r["applied"] else None
def gate_wants_llm(level_name: str, signals: list):
"""Серая зона гейта (только warn/info-сигналы): стоит ли тратить кредиты роутера.
Возвращает (allow: bool | None, p). None — решения нет/shadow: гейт
остаётся на пороговой механике. Порог SIMBIOT_DECISION_GATE_MIN_P (0.35).
"""
if not signals:
return None, None
lines = [f"- {s[0]}: {s[1]} — {s[2]}" for s in signals[:10]]
state = (AUM_GOAL + "\nA full LLM tick costs router credits (direct minus to AUM) "
f"and takes minutes. Level '{level_name}' sees these non-critical "
"signals:\n" + "\n".join(lines))
r = yes_prob("gate", "Do these signals need attention of an expensive LLM "
"agent right now, rather than waiting for the next scheduled tick?",
state)
if not r:
return None, None
try:
thr = float(_cfg("SIMBIOT_DECISION_GATE_MIN_P", "0.35"))
except ValueError:
thr = 0.35
if not r["applied"]:
return None, r["p"]
return r["p"] >= thr, r["p"]
def pick_topic(topics: list, counts: dict, model: dict):
"""Тема инициативной реплики: шортлист ≤ 9 наименее использованных → choice.
Возвращает id темы или None (фолбэк — round-robin).
"""
ranked = sorted(topics, key=lambda t: counts.get(t["id"], 0))[:MAX_OPTIONS]
if len(ranked) < 2:
return None
opts = {t["id"]: t["text"][:300] for t in ranked}
tasks = (model or {}).get("tasks") or {}
active = [t.get("title") or "" for t in tasks.values()
if t.get("status") in ("ready", "running")][:8]
state = "\n".join(x for x in (
AUM_GOAL, _policy_line(model),
"The harness proactively writes one thought to the founder's chat. "
"Topics used less often are listed first.",
("Work in progress: " + "; ".join(a[:80] for a in active)) if active else "",
) if x)
r = choose("topic", opts, state,
"Which topic is the most useful to raise with the founder now "
"to grow long-term AUM?")
return r["choice"] if r and r["applied"] else None
def status() -> dict:
"""Срез для CLI/дашборда: режимы и по каждому бэкенду — доступность,
модель установлена/в памяти (облаку /api/ps не нужен), предохранитель."""
out = {"mode": mode(), "modes": {p: mode(p) for p in ("tasks", "gate", "topic")},
"cache": len(_CACHE), "backends": []}
for be in backends():
b = {"name": be["name"], "url": be["url"], "model": be["model"],
"cloud": bool(be["key"]), "breaker": dict(_BREAKERS.get(be["name"]) or {})}
base = be["url"].split("/v1/")[0]
hdr = {"Authorization": "Bearer " + be["key"]} if be["key"] else {}
want = be["model"] if ":" in be["model"] else be["model"] + ":latest"
try:
req = urllib.request.Request(base + "/api/tags", headers=hdr)
with urllib.request.urlopen(req, timeout=8) as r:
tags = json.loads(r.read().decode())
names = {m.get("name", "") for m in tags.get("models") or []}
b["installed"] = be["model"] in names or want in names
if not be["key"]:
with urllib.request.urlopen(base + "/api/ps", timeout=5) as r:
ps = json.loads(r.read().decode())
b["loaded"] = any(m.get("name") in (be["model"], want)
for m in ps.get("models") or [])
b["reachable"] = True
except Exception as e:
b["reachable"] = False
b["error"] = f"{e.__class__.__name__}: {str(e)[:120]}"
out["backends"].append(b)
return out

View file

@ -127,13 +127,9 @@ LEVELS = {
"cli.py escalate \"причина\" -f hour -s critical. " "cli.py escalate \"причина\" -f hour -s critical. "
"Конфиг уровней — масштабированная контекст-схема по тактам (согласована " "Конфиг уровней — масштабированная контекст-схема по тактам (согласована "
"Алексом, e-261001082710): min 256k → hour 512k → day 1M ctx_tokens. " "Алексом, e-261001082710): min 256k → hour 512k → day 1M ctx_tokens. "
"Пачку задач бери СТРОГО в порядке списка — очередь уже упорядочена " "Пачку задач бери СТРОГО по очереди — от старейшей к свежим, как "
"(FIFO + первой — задача, которую decision-модель сочла самой " "отдаёт очередь (анти-старение); не выбирай «поинтереснее» и "
"ценной для AUM); не выбирай «поинтереснее» и не пересортировывай. " "не пересортировывай пачку. "
"Выбор из 2–9 вариантов (как ответить, какой подход взять) — "
"дёшево и детерминированно: cli.py decide -q \"вопрос\" -o ключ=описание "
"-o ... -s \"ситуация\" (локальная nimble, без кредитов роутера; "
"без -o — P(да)). "
"Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file " "Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file "
"<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). " "<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). "
"Прямые curl/api.telegram отправки из shell запрещены — нет защиты " "Прямые curl/api.telegram отправки из shell запрещены — нет защиты "
@ -155,8 +151,6 @@ LEVELS = {
model="auto", effort="high", ctx_tokens=1_000_000, task_classes=("S", "M", "L"), model="auto", effort="high", ctx_tokens=1_000_000, task_classes=("S", "M", "L"),
mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач " mandate=("Ты — дневной цикл харнеса Simbiot. Манифест дня: план, выбор задач "
"L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд. " "L-класса, дневной отчёт. Думай глубоко, планируй на день вперёд. "
"Выбор из 2–9 вариантов — cli.py decide -q \"вопрос\" -o ключ=описание "
"(локальная decision-модель nimble, без кредитов роутера). "
"Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, " "Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, "
"поднятое с нижних уровней; закрой cli.py escalation-ack <id>. " "поднятое с нижних уровней; закрой cli.py escalation-ack <id>. "
"Файлы в чат — только cli.py tg-deliver-file (идемпотентно, " "Файлы в чат — только cli.py tg-deliver-file (идемпотентно, "

View file

@ -202,20 +202,11 @@ def should_speak(model: dict, now: "datetime | None" = None):
def pick_topic(model: dict): def pick_topic(model: dict):
"""Следующая тема: наименее использованная (round-robin по банку). """Следующая тема: наименее использованная (round-robin по банку)."""
При включённой decision-модели (SIMBIOT_DECISION) — выбор nimble из
шортлиста ≤ 9 наименее использованных с учётом доски и AUM-политики;
модель недоступна — round-robin, как раньше.
"""
init = model.get("initiative") or {} init = model.get("initiative") or {}
counts = {} counts = {}
for s in (init.get("sent") or []): for s in (init.get("sent") or []):
counts[s.get("topic")] = counts.get(s.get("topic"), 0) + 1 counts[s.get("topic")] = counts.get(s.get("topic"), 0) + 1
from . import decision
pick = decision.pick_topic(TOPICS, counts, model)
if pick:
return next(t for t in TOPICS if t["id"] == pick)
return min(TOPICS, key=lambda t: counts.get(t["id"], 0)) return min(TOPICS, key=lambda t: counts.get(t["id"], 0))

View file

@ -157,7 +157,7 @@ def find_task(level_cfg, model: dict):
return None return None
def select_task_batch(level_cfg, model: dict, prioritize: bool = False): def select_task_batch(level_cfg, model: dict):
"""Батч ready-задач доступных классов в бюджете контекста тика. """Батч ready-задач доступных классов в бюджете контекста тика.
Возвращает [task_id, ...] — от старейших к свежим (анти-старение Возвращает [task_id, ...] — от старейших к свежим (анти-старение
@ -171,11 +171,6 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False):
мандат, выжимки, эскалации и инбокс остаются в тике. мандат, выжимки, эскалации и инбокс остаются в тике.
Первая (старейшая) задача берётся всегда — как раньше find_task; Первая (старейшая) задача берётся всегда — как раньше find_task;
бюджетная отсечка работает начиная со второй пачки. бюджетная отсечка работает начиная со второй пачки.
prioritize=True (боевой тик): decision-модель (harness/decision.py,
режим SIMBIOT_DECISION) выбирает из шортлиста ≤ 9 старейших ту, что
больше всего даёт AUM, — она встаёт первой; остальные в FIFO. Модель
недоступна/выключена — чистый FIFO, как раньше.
""" """
classes = level_cfg.task_classes classes = level_cfg.task_classes
if not classes: if not classes:
@ -183,11 +178,6 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False):
ready = [t for t in (model.get("tasks") or {}).values() ready = [t for t in (model.get("tasks") or {}).values()
if t.get("status") == "ready" and t.get("size") in classes] if t.get("status") == "ready" and t.get("size") in classes]
ready.sort(key=lambda t: t.get("created") or "") ready.sort(key=lambda t: t.get("created") or "")
if prioritize and len(ready) > 1:
from . import decision
pick = decision.prioritize_tasks(level_cfg.name, ready, model)
if pick:
ready.sort(key=lambda t: t["id"] != pick) # stable: остальные — FIFO
cap = _batch_cap(level_cfg) cap = _batch_cap(level_cfg)
budget_chars = int(level_cfg.ctx_tokens * CHARS_PER_TOKEN * TASK_BUDGET_SHARE) budget_chars = int(level_cfg.ctx_tokens * CHARS_PER_TOKEN * TASK_BUDGET_SHARE)
picked, used = [], 0 picked, used = [], 0
@ -457,7 +447,7 @@ def build_context(level_cfg, model: dict, batch=None):
lines.append(f" ПОСТАНОВКА: {t['details'][:1800]}") lines.append(f" ПОСТАНОВКА: {t['details'][:1800]}")
parts.append( parts.append(
f"ТЕКУЩАЯ РАБОТА — БАТЧ из {len(batch)} задач (выполняй в этом тике, " f"ТЕКУЩАЯ РАБОТА — БАТЧ из {len(batch)} задач (выполняй в этом тике, "
"по порядку списка — первая самая важная; не растягивай: тик ограничен таймаутом и " "самую старую — ПЕРВОЙ; не растягивай: тик ограничен таймаутом и "
f"бюджетом шагов; суммарная оценка ~{sum(_est_minutes(t) for t in batch)}м):\n" f"бюджетом шагов; суммарная оценка ~{sum(_est_minutes(t) for t in batch)}м):\n"
+ "\n".join(lines) + "\n".join(lines)
+ "\nКаждую завершённую закрывай ОТДЕЛЬНО сразу после выполнения: " + "\nКаждую завершённую закрывай ОТДЕЛЬНО сразу после выполнения: "
@ -552,7 +542,7 @@ def run_level_tick(level_cfg, seq: int, model: dict):
""" """
from . import isolated_runner from . import isolated_runner
batch_ids = select_task_batch(level_cfg, model, prioritize=True) batch_ids = select_task_batch(level_cfg, model)
batch = [model["tasks"].get(tid) for tid in batch_ids] batch = [model["tasks"].get(tid) for tid in batch_ids]
batch = [t for t in batch if t] batch = [t for t in batch if t]
claimed = [] claimed = []
@ -887,16 +877,7 @@ def process_level(level_cfg, model: dict):
except Exception: except Exception:
load = None load = None
signals = collect_signals(level_cfg, model, load) signals = collect_signals(level_cfg, model, load)
allow = gate_allows_llm(level_cfg, signals) if not gate_allows_llm(level_cfg, signals):
decided_p = None
if allow and signals and not any(s[0] == "critical" for s in signals):
# серая зона: порог пропустил только warn/info — спросить
# decision-модель, стоит ли тратить кредиты (critical — всегда LLM)
from . import decision
verdict, decided_p = decision.gate_wants_llm(level_cfg.name, signals)
if verdict is False:
allow = False
if not allow:
period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc)
es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", { es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", {
"level": level_cfg.name, "period": period.isoformat(), "level": level_cfg.name, "period": period.isoformat(),
@ -908,7 +889,6 @@ def process_level(level_cfg, model: dict):
es.append_event("cycle", f"gate:{level_cfg.name}:{seq}", "GateSkipped", { es.append_event("cycle", f"gate:{level_cfg.name}:{seq}", "GateSkipped", {
"level": level_cfg.name, "level": level_cfg.name,
"threshold": _gate_threshold(level_cfg), "threshold": _gate_threshold(level_cfg),
"decision_p": decided_p,
"signals": [{"severity": s[0], "what": s[1], "detail": s[2]} "signals": [{"severity": s[0], "what": s[1], "detail": s[2]}
for s in signals][:10], for s in signals][:10],
}) })

View file

@ -72,17 +72,6 @@ random search до +59%. Что уже известно (не переоткры
### 4. Интеграция в тик харнеса ### 4. Интеграция в тик харнеса
Внедрено: `harness/decision.py` (README харнеса, раздел «Decision-модель
nimble») — порядок батча задач, тема инициативы, серая зона гейта (shadow),
CLI `cli.py decide -q "вопрос" -o ключ=описание ... -s "ситуация"` для
выбора из 2–9 вариантов прямо из тика. Бэкенды: Ollama Cloud `clef:27b`
(~0.7 с, ключ SIMBIOT_DECISION_API_KEY) → запасная локальная nimble на
CPU (10–30 с). Из decision-моделей облака проверены: clef:27b — работает;
beam, muse-spark-open, glm-5.3-flash и прочие LLM — «not a decision
model»; nimble в облаке нет. Батчи вопросы в один запрос (до 64).
Исходная схема:
На тиках, где нужен выбор действия: собрать кандидатов → разбить на группы → На тиках, где нужен выбор действия: собрать кандидатов → разбить на группы →
stage1 score (top-2) → choice ≤9 → применить → записать выбор и исход событием stage1 score (top-2) → choice ≤9 → применить → записать выбор и исход событием
в стор (для последующей GA-сшивки policy trace с fitness). в стор (для последующей GA-сшивки policy trace с fitness).

View file

@ -1,225 +0,0 @@
# -*- coding: utf-8 -*-
"""decision-модель (harness/decision.py) — hermetic, фейковый /v1/systemone.
Запуск: ./venv/bin/python tests/test_decision.py
Проверяет: off — эндпоинт не зовётся и очередь FIFO; on — выбор модели
встаёт первым в батче, остальные FIFO; shadow — решение пишется, но не
применяется; шортлист ≤ 9; кеш (повтор не идёт в сеть, стор не спамится);
предохранитель (мёртвый эндпоинт → None быстро, второй вызов без сети);
гейт: серая зона p<порога → False, p≥порога → True, гейт по умолчанию shadow;
тема инициативы — выбор модели, при off — round-robin.
"""
import json
import os
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
sys.path.insert(0, "/root/simbiot-harness")
_tmp = tempfile.mkdtemp(prefix="simbiot_decision_")
os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db")
for _v in [k for k in os.environ if k.startswith("SIMBIOT_DECISION")] + [
"SIMBIOT_BATCH_MAX_MIN", "SIMBIOT_BATCH_MAX_HOUR", "SIMBIOT_BATCH_MAX_DAY"]:
os.environ.pop(_v, None)
from harness import decision, es, scheduler, proactive
from harness.levels import LEVELS
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
# ── фейковый эндпоинт: choice → ключ из FAKE["pick"] (или последний), noul → FAKE["p"]
FAKE = {"pick": None, "p": 0.2, "calls": [], "delay": 0.0}
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
body["_auth"] = self.headers.get("Authorization")
body["_path"] = self.path
FAKE["calls"].append(body)
time.sleep(FAKE["delay"])
ans = {}
for qid, q in body["questions"].items():
if q["type"] == "choice":
keys = list(q["criteria"])
pick = FAKE["pick"] if FAKE["pick"] in keys else keys[-1]
ans[qid] = {"type": "choice", "choice": pick, "confidence": 0.5,
"probabilities": {k: (0.9 if k == pick else 0.1 / (len(keys) - 1))
for k in keys}}
else:
ans[qid] = {"type": "noul", "noul": FAKE["p"]}
raw = json.dumps({"model": body["model"], "answers": ans}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
srv = HTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
os.environ["SIMBIOT_DECISION_URL"] = f"http://127.0.0.1:{srv.server_port}/v1/systemone"
def reset():
FAKE.update(pick=None, p=0.2, calls=[], delay=0.0)
decision._CACHE.clear()
decision._BREAKERS.clear()
def _task(tid, created, title="задача", size="M"):
return {"id": tid, "title": title, "size": size, "status": "ready",
"created": created, "details": ""}
def _model(n):
tasks = {f"t{i}": _task(f"t{i}", f"2026-10-0{i}T00:00:00" if i < 10
else f"2026-10-{i}T00:00:00", title=f"задача {i}")
for i in range(1, n + 1)}
return {"tasks": tasks, "policy": {"aum_target": "AUM ≥ +1%/год"}}
def _decisions():
return [json.loads(e["data"]) for e in es.get_events(0, 100000)
if e["event_type"] == "DecisionMade"]
hour = LEVELS["hour"]
# 1) off по умолчанию: сеть не трогаем, FIFO
reset()
check("mode off по умолчанию", decision.mode() == "off" and decision.mode("tasks") == "off")
ids = scheduler.select_task_batch(hour, _model(4), prioritize=True)
check("off: FIFO", ids[:1] == ["t1"], ids)
check("off: эндпоинт не вызывался", not FAKE["calls"])
# 2) on: выбор модели — первым, остальные FIFO
os.environ["SIMBIOT_DECISION"] = "on"
reset()
FAKE["pick"] = "t3"
ids = scheduler.select_task_batch(hour, _model(4), prioritize=True)
check("on: выбор модели первым", ids[0] == "t3", ids)
check("on: остальные FIFO", ids[1:] == ["t1", "t2", "t4"], ids)
check("on: без prioritize — чистый FIFO",
scheduler.select_task_batch(hour, _model(4))[0] == "t1")
d = _decisions()
check("DecisionMade записан", d and d[-1]["purpose"] == "tasks" and d[-1]["choice"] == "t3"
and d[-1]["applied"] is True, d[-1:] if d else None)
# 3) кеш: та же очередь — без сети и без нового события
n_calls, n_ev = len(FAKE["calls"]), len(_decisions())
scheduler.select_task_batch(hour, _model(4), prioritize=True)
check("кеш: повтор без сети", len(FAKE["calls"]) == n_calls)
check("кеш: стор не спамится", len(_decisions()) == n_ev)
# 4) шортлист ≤ 9 старейших
reset()
scheduler.select_task_batch(hour, _model(14), prioritize=True)
crit = FAKE["calls"][-1]["questions"]["c"]["criteria"]
check("шортлист ≤ 9", len(crit) == 9, len(crit))
check("шортлист — старейшие", list(crit) == [f"t{i}" for i in range(1, 10)], list(crit))
check("keep_alive передан", FAKE["calls"][-1].get("keep_alive"))
# 5) shadow: решение записано, но FIFO
os.environ["SIMBIOT_DECISION"] = "shadow"
reset()
FAKE["pick"] = "t2"
ids = scheduler.select_task_batch(hour, _model(3), prioritize=True)
check("shadow: FIFO остаётся", ids[0] == "t1", ids)
check("shadow: решение записано с applied=False",
_decisions()[-1]["choice"] == "t2" and _decisions()[-1]["applied"] is False)
# 6) предохранитель: мёртвый эндпоинт → None, второй вызов без сети
os.environ["SIMBIOT_DECISION"] = "on"
reset()
os.environ["SIMBIOT_DECISION_URL"] = "http://127.0.0.1:9/v1/systemone"
ids = scheduler.select_task_batch(hour, _model(3), prioritize=True)
check("мёртвый эндпоинт: FIFO", ids[0] == "t1", ids)
check("предохранитель взведён", decision._BREAKERS["primary"]["until"] > time.time())
t0 = time.time()
r = decision.choose("tasks", {"a": "x", "b": "y"}, "s", "q")
check("предохранитель: мгновенный None", r is None and time.time() - t0 < 0.5)
os.environ["SIMBIOT_DECISION_URL"] = f"http://127.0.0.1:{srv.server_port}/v1/systemone"
# 6b) облако + локальный запасной: ключ в Authorization, без keep_alive;
# облако упало → ответ запасного, бэкенд записан в DecisionMade
reset()
port = srv.server_port
os.environ["SIMBIOT_DECISION_API_KEY"] = "k-test"
os.environ["SIMBIOT_DECISION_URL"] = f"http://127.0.0.1:{port}/cloud/v1/systemone"
os.environ["SIMBIOT_DECISION_FALLBACK_URL"] = f"http://127.0.0.1:{port}/local/v1/systemone"
bes = decision.backends()
check("с ключом: облако clef:27b + запасная nimble",
[b["model"] for b in bes] == ["clef:27b", "nimble"], bes)
FAKE["pick"] = "b"
r = decision.choose("agent", {"a": "x", "b": "y"}, "s-cloud", "q")
c = FAKE["calls"][-1]
check("облако: выбор", r and r["choice"] == "b", r)
check("облако: Bearer-ключ", c["_auth"] == "Bearer k-test", c["_auth"])
check("облако: без keep_alive", "keep_alive" not in c)
check("облако: путь основного", c["_path"].startswith("/cloud/"), c["_path"])
reset()
os.environ["SIMBIOT_DECISION_URL"] = "http://127.0.0.1:9/v1/systemone"
FAKE["pick"] = "a"
r = decision.choose("agent", {"a": "x", "b": "y"}, "s-fallback", "q")
c = FAKE["calls"][-1] if FAKE["calls"] else {}
check("облако упало → запасной ответил", r and r["choice"] == "a", r)
check("запасной: путь локального, без ключа",
c.get("_path", "").startswith("/local/") and c.get("_auth") is None, c.get("_path"))
check("запасной: предохранитель облака взведён",
decision._BREAKERS.get("primary", {}).get("until", 0) > time.time())
dm = _decisions()[-1]
check("DecisionMade: backend=fallback, model=nimble, ошибка облака сохранена",
dm.get("backend") == "fallback" and dm.get("model") == "nimble" and dm.get("error"), dm)
for _v in ("SIMBIOT_DECISION_API_KEY", "SIMBIOT_DECISION_FALLBACK_URL"):
os.environ.pop(_v)
os.environ["SIMBIOT_DECISION_URL"] = f"http://127.0.0.1:{port}/v1/systemone"
check("без ключа: только локальная (как раньше)",
[b["name"] for b in decision.backends()] == ["primary"])
# 7) гейт: по умолчанию shadow (не влияет), с GATE=on — по порогу
reset()
sigs = [("warn", "escalation:e-1", "что-то не так")]
check("гейт по умолчанию shadow", decision.mode("gate") == "shadow")
v, p = decision.gate_wants_llm("hour", sigs)
check("гейт shadow: вердикта нет, p записан", v is None and abs(p - 0.2) < 1e-9, (v, p))
os.environ["SIMBIOT_DECISION_GATE"] = "on"
reset()
FAKE["p"] = 0.1
check("гейт on: p<порога → False", decision.gate_wants_llm("hour", sigs)[0] is False)
reset()
FAKE["p"] = 0.8
check("гейт on: p≥порога → True", decision.gate_wants_llm("hour", sigs)[0] is True)
os.environ.pop("SIMBIOT_DECISION_GATE")
# 8) тема инициативы: выбор модели; при off — round-robin
reset()
FAKE["pick"] = "cyberbank"
check("тема: выбор модели", proactive.pick_topic({"tasks": {}})["id"] == "cyberbank")
os.environ["SIMBIOT_DECISION"] = "off"
check("тема off: round-robin", proactive.pick_topic({"tasks": {}})["id"] == proactive.TOPICS[0]["id"])
# 9) глобальный shadow — потолок для точечного on
os.environ["SIMBIOT_DECISION"] = "shadow"
os.environ["SIMBIOT_DECISION_TASKS"] = "on"
check("глобальный shadow сильнее точечного on", decision.mode("tasks") == "shadow")
os.environ.pop("SIMBIOT_DECISION_TASKS")
srv.shutdown()
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)