simbiot-harness/harness/decision.py
Simbiot Harness 3ff504143a адресация: группы без chat_type по chat_id<0; облако decision — таймаут 30 с, кулдаун 60 с
- старые записи инбокса без chat_type (до t-261013113629) считались
  личкой и обходили фильтр адресации — группа теперь и по chat_id < 0;
- утренний холодный старт clef:27b в облаке (> 15 с) взводил
  предохранитель на 5 минут → ответы от CPU-nimble по 10 с: облако
  ждём до 30 с, его кулдаун 60 с (локальной — по-прежнему 5 минут).
Реплей реальной истории «Правления» (62 сообщения, бот отвечает только
на адресованное, облако ~0.66 с): приветствия и переписка людей между
собой отфильтрованы; разговор Alex с ботом без упоминания распознан
по контексту; спорные — 2–3 в каждую сторону, «Коди»/@/reply — всегда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:13:35 +00:00

394 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 минут, не тормозим тики таймаутами
# облако: холодный старт clef после простоя бывает > 15 с (утро 2026-10-17:
# один таймаут → 5 минут ответов от CPU-nimble по 10 с) — короткий кулдаун
CLOUD_BREAKER_COOLDOWN = 60.0
_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", 30.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:
cool = CLOUD_BREAKER_COOLDOWN if be["key"] else BREAKER_COOLDOWN
_BREAKERS[be["name"]] = {"until": time.time() + cool, "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