"""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_=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