From 871a3b15a3fd310ed292f9e6b74ade92e7b33647 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Wed, 30 Sep 2026 08:29:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(gate):=20=D0=B3=D0=B5=D0=B9=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20LLM-=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20per-level=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=83=20+=20model=20=D0=B2=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - гейт (e-260930062242/2510/2607/2741): collect_signals ранжирует события (unreplied_inbox/ready_task/critical-эскалация = critical; warn-эскалации, high_load = warn), gate_allows_llm решает по порогу уровня (SIMBIOT_GATE_, дефолты min=critical/hour=warn/day=warn); тихий такт — GateSkipped + дайджест-заглушка, без LLM и без расхода роутера - model в BudgetSpent/InvocationStarted (e-260930062120): видно, какая модель и уровень тратили кредиты (strands не выносит фактическую модель в usage — пишем запрошенный model_id) - per-level лимиты тул-колов уже в дереве: resolve_tool_budget (min=30/hour=300, env-оверрайд) (e-260930060717/060904) - tests/test_gate.py: 28 hermetic-проверок, ALL PASS - simbiot-presentation.html: презентация Simbiot (t-260930053538), 10 слайдов --- harness/_llm_worker.py | 34 ++++- harness/runtime.py | 14 +- harness/scheduler.py | 105 +++++++++++++++ simbiot-presentation.html | 260 ++++++++++++++++++++++++++++++++++++++ tests/test_gate.py | 145 +++++++++++++++++++++ 5 files changed, 548 insertions(+), 10 deletions(-) create mode 100644 simbiot-presentation.html create mode 100644 tests/test_gate.py diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index 73d0b58..f162536 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -147,13 +147,18 @@ def _extract_usage(agent) -> dict: return usage -def _emit_budget_incremental(level: str, trace_id: str, usage_delta: dict): +def _emit_budget_incremental(level: str, trace_id: str, usage_delta: dict, + model: str = None): """Записать BudgetSpent за один модельный вызов. Воркер — единственный автор spend-событий, но scheduler может убить его по таймауту в любой момент (SIGKILL). Поэтому usage пишем ИНКРЕМЕНТАЛЬНО: каждый завершённый модельный вызов — своё событие. Хвост недописанного вызова теряется, но весь тик — никогда. + + model (e-260930062120): запрошенный model_id уровня пишется в каждое + событие — в кредитах роутера видно, ЧЬИ это траты; "auto" резолвится + роутером, фактическую модель до usage strands не выносит. """ if not usage_delta: return @@ -161,13 +166,14 @@ def _emit_budget_incremental(level: str, trace_id: str, usage_delta: dict): from harness import es es.append_event("harness", "budget", "BudgetSpent", { "level": level, "trace_id": trace_id, "source": "worker", + **({"model": model} if model else {}), **usage_delta, }) except Exception: pass # трекинг не должен валить тик -def _wire_budget_hooks(agent, level: str, trace_id: str): +def _wire_budget_hooks(agent, level: str, trace_id: str, model: str = None): """После каждого модельного вызова — usage-дельта в стор. Strands вешает usage на assistant-сообщение в @@ -194,12 +200,27 @@ def _wire_budget_hooks(agent, level: str, trace_id: str): seen.add(key) _emit_budget_incremental(level, trace_id, { "prompt_tokens": p, "completion_tokens": c, - }) + }, model=model) agent.add_hook(on_message_added, MessageAddedEvent) return agent +def resolve_tool_budget(level: str) -> int: + """Бюджет тул-коллов тика по уровню (e-260930071900, просьба Alex: + часовому уровню нужен большой цикл — «хоть 300»). + + Приоритет: SIMBIOT_MAX_TOOL_CALLS_ > SIMBIOT_MAX_TOOL_CALLS + > per-level дефолт. Минута остаётся компактной (30) — жёсткий кап + и спасает её от таймаута (e-260930031800: 90+ шагов → 300с → смерть). + """ + env = (os.environ.get(f"SIMBIOT_MAX_TOOL_CALLS_{level.upper()}") + or os.environ.get("SIMBIOT_MAX_TOOL_CALLS")) + if env: + return int(env) + return {"min": 30, "hour": 300}.get(level, 30) + + def main(): payload = json.loads(sys.stdin.read()) @@ -236,8 +257,8 @@ def main(): memory=False, skills=False, ) - agent = _wire_trace_hooks(agent, es, trace_id) - agent = _wire_budget_hooks(agent, level, trace_id) + agent = _wire_trace_hooks(agent, es, trace_id, model=payload.get("model")) + agent = _wire_budget_hooks(agent, level, trace_id, model=payload.get("model")) # хард-кап шагов (e-260930031800): cancel_tool + end_turn. # ВАЖНО: НЕ используем limits={"turns": N} — нативный стоп SDK # обрывает цикл на границе хода, и result.message остаётся @@ -245,8 +266,7 @@ def main(): # (репродукция: stop_reason=limit_turns, text='', тики 04:18+). # Наш end_turn (AfterToolsEvent) кладёт в историю ассистентский # текст с итогом — дайджест не пустеет. - agent = _wire_tool_budget( - agent, int(os.environ.get("SIMBIOT_MAX_TOOL_CALLS") or 30)) + agent = _wire_tool_budget(agent, resolve_tool_budget(level)) result = agent(payload.get("prompt", "")) usage = _extract_usage(agent) _emit(payload, {"ok": True, "text": str(result), "usage": usage, "trace_id": trace_id}) diff --git a/harness/runtime.py b/harness/runtime.py index d4ebb59..cf39b5e 100644 --- a/harness/runtime.py +++ b/harness/runtime.py @@ -61,8 +61,13 @@ def setup_provider(): os.environ["OPENAI_BASE_URL"] = _router_base_url() -def _wire_trace_hooks(agent, es, trace_id: str): - """Каждая стадия агентного цикла → событие агрегата trace.""" +def _wire_trace_hooks(agent, es, trace_id: str, model: str = None): + """Каждая стадия агентного цикла → событие агрегата trace. + + model (e-260930062120): model_id уровня в InvocationStarted — + видно, КАКАЯ модель запускалась на тике (запрошенная; "auto" + резолвится роутером на его стороне). + """ from strands import Agent from strands.hooks import BeforeInvocationEvent, BeforeToolCallEvent, AfterToolCallEvent @@ -73,7 +78,10 @@ def _wire_trace_hooks(agent, es, trace_id: str): es.append_event("trace", f"{trace_id}:{seq['n']}", kind, payload) def on_before_invocation(event): - _trace("InvocationStarted", {"trace_id": trace_id, "ts": _now()}) + data = {"trace_id": trace_id, "ts": _now()} + if model: + data["model"] = model + _trace("InvocationStarted", data) def on_before_tool(event): tool = getattr(event, "tool_use", None) or {} diff --git a/harness/scheduler.py b/harness/scheduler.py index 98eeb24..4e13214 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -74,6 +74,85 @@ def _lvl_rank(name: str) -> int: return _ORDER.index(name) if name in _ORDER else len(_ORDER) +# ────────────────────────── ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741) ────────────────────────── +# Alex: LLM-тик уровня запускается ТОЛЬКО если есть событие ≥ порога важности +# этого уровня; иначе тик отрабатывает чисто детерминированной механикой (как sec). +# Ранжирование: critical — входящее TG без ответа, готовая задача уровня, +# открытая critical-эскалация; warn — warn-эскалации, высокая нагрузка; +# всё остальное — тишина, LLM не нужен (не тратим кредиты роутера). + +# порог по умолчанию на уровень (env-оверрайд: SIMBIOT_GATE_=critical|warn|off) +_GATE_DEFAULTS = {"min": "critical", "hour": "warn", "day": "warn"} + + +def _gate_threshold(level_cfg) -> str: + """Порог важности уровня: off — гейт выключен, LLM всегда.""" + env = os.environ.get(f"SIMBIOT_GATE_{level_cfg.name.upper()}") + if env: + return env.strip().lower() + return _GATE_DEFAULTS.get(level_cfg.name, "off") + + +def _pending_inbox(model: dict): + """Входящие TG без ответа (не команды) — материал для минутного LLM.""" + from . import tg_commands + return [m for m in (model.get("inbox") or {}).values() + if not m.get("replied") and not m.get("ignored") + and not tg_commands.parse(m.get("text") or "") + and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))] + + +def collect_signals(level_cfg, model: dict, load: float = None): + """События, важные именно для этого уровня, с рангом важности. + + Возвращает [(severity, what, detail)] — чистая функция от read model, + без побочных эффектов; используется гейтом LLM и тестом (hermetic). + """ + sigs = [] + # 1) входящее TG без ответа — критично (должен ответить этот или нижний уровень) + inbox = _pending_inbox(model) + if inbox: + ids = ", ".join(("m-" + str(m.get("update_id"))) for m in inbox[:5]) + sigs.append(("critical", "unreplied_inbox", + f"{len(inbox)} сообщений без ответа ({ids})")) + # 2) готовая задача доступного уровня классу — критично (тик должен её взять) + ready = [t for t in (model.get("tasks") or {}).values() + if t.get("status") == "ready" and t.get("size") in level_cfg.task_classes] + if ready: + ids = ", ".join(f"{x['id']}[{x.get('size')}]" for x in + sorted(ready, key=lambda x: x.get("created", ""))[:5]) + sigs.append(("critical", "ready_task", f"готовые задачи: {ids}")) + # 3) открытые эскалации, адресованные этому уровню и выше — ранг = severity + for e in (model.get("escalations") or {}).values(): + if not isinstance(e, dict) or e.get("status") != "open" or e.get("stub"): + continue + to_level = e.get("to_level", "day") + if to_level not in _ORDER or _lvl_rank(level_cfg.name) < _lvl_rank(to_level): + continue + sev = e.get("severity", "info") + if sev in ("critical", "warn", "info"): + sigs.append((sev, f"escalation:{e.get('id')}", + (e.get("reason") or "")[:120])) + # 4) высокая нагрузка — вторично (алерт-порог как у сек-рефлекса) + if load is not None: + thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8) + if load >= thr: + sigs.append(("warn", "high_load", f"load1 {load:.2f} >= {thr}")) + return sigs + + +def gate_allows_llm(level_cfg, signals) -> bool: + """Решение гейта: запускать ли LLM-тик по порогу важности уровня.""" + thr = _gate_threshold(level_cfg) + if thr == "off": + return True + order = {"critical": 0, "warn": 1, "info": 2} + if thr not in order: + return True + rank = order[thr] + return any(order.get(s[0], 3) <= rank for s in signals) + + def build_context(level_cfg, model: dict, task=None): """Сборка контекста тика: мандат + память уровня + выжимки нижних + задача.""" parts = [level_cfg.mandate] @@ -265,6 +344,32 @@ def process_level(level_cfg, model: dict): if not emit_tick(level_cfg, seq): return "already" # тик этого такта уже был (идемпотентность) + # ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741): LLM-тик запускается + # только если есть событие ≥ порога уровня; тихий такт отрабатывает + # детерминированной механикой (как sec) — без расхода кредитов роутера. + if level_cfg.uses_llm: + try: + load = os.getloadavg()[0] + except Exception: + load = None + signals = collect_signals(level_cfg, model, load) + if not gate_allows_llm(level_cfg, signals): + period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) + es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", { + "level": level_cfg.name, "period": period.isoformat(), + "summary_ref": None, + "text": f"[gate:quiet] {level_cfg.name}: событий важнее порога нет " + f"({len(signals)} ниже порога) — LLM не запускался", + "stub": True, + }) + es.append_event("cycle", f"gate:{level_cfg.name}:{seq}", "GateSkipped", { + "level": level_cfg.name, + "threshold": _gate_threshold(level_cfg), + "signals": [{"severity": s[0], "what": s[1], "detail": s[2]} + for s in signals][:10], + }) + return f"gate:quiet ({len(signals)} signals below threshold)" + st["busy"] = True try: if not level_cfg.uses_llm: diff --git a/simbiot-presentation.html b/simbiot-presentation.html new file mode 100644 index 0000000..14a8def --- /dev/null +++ b/simbiot-presentation.html @@ -0,0 +1,260 @@ + + + + + +Simbiot — общий AI-харнес организации · презентация + + + +
+ +
+
Презентация · Simbiot
+

AI-харнес организации из коробки

+
Студийная ОС, где агенты, проекты и метрики живут в одной системе. Фаундер приходит со своей идеей — а инфраструктуру, которую обычно собирают год, получает с первого дня. Проект на 100% остаётся у фаундера, оплата — из выручки.
+
+ Студийная ОС + Слой над агентами + US LLC · Stripe · дашборды + Оплата из выручки + Кооператив 42 · 2026 +
+
+ + +
+
01 / СУТЬ
+

Что вы создаёте

+

Общий AI-харнес организации «из коробки»: не отдельный агент, а среда, в которой агенты, проекты и метрики работают как единая система.

+
+
1-й день
вместо года сборки
+
US LLC
юрконтур готов
+
Stripe
платёжка живая
+
1 система
агенты · проекты · метрики
+
+

Фаундер приносит идею — харнес даёт ей операционное тело: юрлицо в США, эквайринг, агентов и дашборды. Всё, что обычно тормозит запуск на месяцы, уже собрано и работает на реальных продуктах.

+
Одной фразой: фаундер получает готовый бизнес-контур под своей идеей, а не голый набор инструментов.
+
+ + +
+
02 / ПРОБЛЕМА
+

Какую проблему решаем — и для кого

+

Продукт готов на 40%. Остальные 60% — бизнес-контур: юрлицо, платёжка, продажи, метрики. И его некому собирать.

+
+
+

Инди-фаундер / малая команда

+
    +
  • Нет времени и экспертизы собирать бизнес-контур — разработкой занят
  • +
  • Аутсорс дорог, фултайм — дороже
  • +
  • Запуск буксует месяцами: не из-за кода, а из-за «обвязки»
  • +
+
+
+

Разработчики harness'ов

+
    +
  • Каждый строит свой harness — и уносит его с собой
  • +
  • Опыт не переиспользуется, знание живёт в головах
  • +
  • Между собой не делятся: повторяют одну работу по сто раз
  • +
+
+
+

Итог: рынок полон продуктов, которые «почти запущены» — и людей, которые десятками раз собирают одно и то же, каждый для себя.

+
+ + +
+
03 / РЕШЕНИЕ
+

Слой над агентами, а не ещё один агент

+

Simbiot — это общий харнес организации с «нулевой точкой воздействия»: он переписывает сам себя и координирует целый флот агентов.

+
+
+
01
+

Общий харнес

+

Единая среда организации: агенты, проекты, метрики, память — в одной системе, а не в зоопарке скриптов у каждого в репо.

+
+
+
02
+

Нулевая точка воздействия

+

Харнес переписывает сам себя: собственная доработка — это обычная рабочая задача внутри системы, а не внешняя миграция.

+
+
+
03
+

Координатор флота

+

Не один мега-агент, а управление целым флотом: у каждого агента своя роль, у системы — общий ритм и приоритеты.

+
+
+
04
+

Реестр протоколов

+

Решения фиксируются письменно. Агент исполняет протокол, а не догадывается, что имел в виду человек.

+
+
+
Почему пользователь выберет нас: вместо «ещё одного агента» он получает организационный слой, который держит и агентов, и память, и правила игры — и который может дорабатывать сам себя.
+
+ + +
+
04 / КАК ЭТО РАБОТАЕТ
+

Путь фаундера — от идеи до работающего бизнеса

+

Фаундер делает стратегические решения и держит фокус на продукте. Операционный контур собирает и ведёт Simbiot.

+
+
ШАГ 1

Идея

Фаундер приходит со своей идеей и MVP-видением

+
ШАГ 2

Контур

US LLC, Stripe, агенты и дашборды подключены с первого дня

+
ШАГ 3

Агенты

Флот агентов берёт рутину: разработка, контент, поддержка, аналитика

+
ШАГ 4

Метрики

Дашборды показывают состояние проектов и AUM в реальном времени

+
ШАГ 5

Выручка

Оплата из выручки — когда проект заработает

+
+
+ + +
+
05 / УСТРОЙСТВО
+

Ритмы: харнес живёт тактами, как человек

+

Simbiot Harness — живой процесс с собственной временной осью, а не функция, которую вызывают по запросу.

+
+
СЕРДЦЕБИЕНИЕ

Секунда

Рефлексы: детерминированные реакции без LLM — мгновенный отклик

+
РЕАКЦИЯ

Минута

Триаж входящих, мелкие задачи S, первые ответы

+
ПОТОК

Час

Дайджесты, задачи M, разбор накопившегося

+
ПЛАН ДНЯ

День

Манифест дня, большие задачи L, планирование

+
РЕТРО

Неделя / далее

Цели, стратегия, идентичность: чем крупнее цикл — тем больше контекста и задач

+
+

Всё происходящее — тики, решения, трата бюджета — это события в append-only сторе. Состояние не хранится, а выводится replay'ем: выключили и включили процесс — он продолжил с того же места.

+
+ + +
+
06 / ПАМЯТЬ
+

Письменные протоколы вместо догадок

+

Реестр решений — ядро долгосрочной работы агентов: организация помнит, почему принято каждое решение.

+
    +
  • Решение = запись. Каждое значимое решение фиксируется письменно, с контекстом и мотивировкой
  • +
  • Агент исполняет, а не угадывает. Протокол — источник истины; поведение агентов воспроизводимо
  • +
  • Интеллект не уходит с человеком. Память организации накапливается в системе, а не в головах сотрудников
  • +
  • Кооператив и агенты растут вместе: агенты снимают рутину, люди строят продуктовую и метрическую часть
  • +
+
Следствие: организация получает общий интеллект и память, которые переживают уход любого отдельного участника.
+
+ + +
+
07 / ОТЛИЧИЯ
+

Simbiot против привычных сценариев

+

Рынок предлагает либо «агента в помощь», либо полный аутсорс. Simbiot занимает другую нишу — общий организационный слой.

+ + + + + + +
СценарийЧто даётЧего не хватает
SimbiotХарнес организации: агенты + контур + память + метрики, из коробки, дорабатывает сам себя—
Свой harness / фреймворкПолный контроль, своя архитектураСобирать год, поддерживать самому, знание не передаётся
Ещё один AI-агент (SaaS)Быстрый старт на одной задачеНет организации вокруг: ни юрлица, ни памяти, ни координации флота
Аутсорс-студияГотовая команда под заказДорого, знание уходит с командой, метрики — в чужих руках
+
+ + +
+
08 / ЭКОНОМИКА
+

Оплата из выручки — риск на нашей стороне

+

Проект на 100% принадлежит фаундеру. Мы зарабатываем, когда заработал проект — выровненные стимулы вместо предоплаты за обещания.

+
+
100%
проекта у фаундера
+
0
предоплат за сборку контура
+
$0
до первой выручки проекта
+
+

Нам выгодно, чтобы проект зарабатывал быстрее и дольше — вся модель мотивации смотрит в одну сторону. Фаундер рискует идеей и временем, мы — инфраструктурой и агентами.

+
+ + +
+
09 / КОГДА ЭТО ВЫГОДНО
+

Для кого Simbiot — правильный выбор

+

Чем меньше команда и чем дальше она от операционки — тем выше выигрыш.

+
    +
  • Инди-фаундер с MVP: бизнес-контур нужен, а собирать его некому и некогда
  • +
  • Малая команда без операционного директора: рутина и обвязка съедают половину силы команды
  • +
  • Технический основатель: хочет строить продукт, а не юрлицо, платёжку и дашборды
  • +
  • Существующий harness-владелец: готов перестать держать всё в своих руках и получить общий слой
  • +
+
+ + +
+
10 / ИТОГ
+

Результат с первого дня

+

Фаундер получает не «инструмент», а работающую организационную систему — и фокусирует своё время там, где создаётся ценность.

+
+
ИЗ КОРОБКИ

Контур

US LLC, Stripe, агенты, дашборды — уже в первый день, а не через год

+
ПОД ИДЕЮ

Фокус

Фаундер занимается продуктом; рутины и операционку держат агенты

+
НАД АГЕНТАМИ

Слой

Координация флота, письменные протоколы, самодоработка харнеса

+
ИЗ ВЫРУЧКИ

Риск

Оплата когда заработало; проект на 100% у фаундера

+
+
Simbiot — это общий интеллект и память кооператива, которые не уходят вместе с человеком: организация и её агенты растут вместе.
+
+ +
+ SIMBIOT · ПРЕЗЕНТАЦИЯ ПРОДУКТА + КООПЕРАТИВ 42 · 2026 + ПРОЕКТ 100% У ФАУНДЕРА · ОПЛАТА ИЗ ВЫРУЧКИ +
+
+ + \ No newline at end of file diff --git a/tests/test_gate.py b/tests/test_gate.py new file mode 100644 index 0000000..7f8b610 --- /dev/null +++ b/tests/test_gate.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""e-260930062242/2510/2607/2741: гейт важности LLM-тика (hermetic, без LLM/БД). + +Запуск: ./venv/bin/python tests/test_gate.py + +Проверяет: collect_signals ранжирует события (critical/warn), +gate_allows_llm решает по per-level порогу (SIMBIOT_GATE_), +тихий тик не запускает LLM — гейт универсален для всех уровней. +""" +import os +import sys + +sys.path.insert(0, "/root/simbiot-harness") + +# чистое окружение: env-капы гейта не должны влиять на тесты +for _v in ("SIMBIOT_GATE_MIN", "SIMBIOT_GATE_HOUR", "SIMBIOT_GATE_DAY"): + os.environ.pop(_v, None) + +from harness import scheduler +from harness.levels import LEVELS + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") + if not cond: + fails.append(name) + + +def _model(tasks=None, inbox=None, escs=None, policy=None): + return { + "tasks": tasks or {}, "inbox": inbox or {}, + "escalations": escs or {}, "policy": policy or {}, + } + + +# ── 1. Тишина: нет событий — гейт не пускает LLM ни на одном LLM-уровне ── +empty = _model() +for name in ("min", "hour", "day"): + cfg = LEVELS[name] + sigs = scheduler.collect_signals(cfg, empty) + check(f"{name}: пустая модель → нет сигналов", sigs == [], str(sigs)) + check(f"{name}: тихий тик → LLM не запускается", + scheduler.gate_allows_llm(cfg, sigs) is False) + +# ── 2. Входящее TG без ответа — critical, минутный уровень просыпается ── +inbox = {"m-1": {"update_id": 1, "text": "привет", "replied": False, "ignored": False}, + "m-2": {"update_id": 2, "text": "/лестница", "replied": False, "ignored": False}} +sigs = scheduler.collect_signals(LEVELS["min"], _model(inbox=inbox)) +crit = [s for s in sigs if s[0] == "critical" and s[1] == "unreplied_inbox"] +check("unreplied_inbox: 1 сигнал critical", len(crit) == 1, str(sigs)) +check("unreplied_inbox: посчитаны только не-команды (1 из 2)", + crit and "1 сообщений без ответа" in crit[0][2], crit[0][2] if crit else "") +check("min: критичный сигнал → LLM запускается", + scheduler.gate_allows_llm(LEVELS["min"], sigs) is True) + +# ── 3. Готовая задача доступного класса — critical ── +tasks = {"t-1": {"id": "t-1", "status": "ready", "size": "M", "created": "2026-09-30T08:00:00"}} +sigs = scheduler.collect_signals(LEVELS["hour"], _model(tasks=tasks)) +check("hour: ready M-задача → critical", any( + s[0] == "critical" and s[1] == "ready_task" for s in sigs), str(sigs)) +# та же задача недоступна минутному уровню (класс M) — сигнала нет +sigs_min = scheduler.collect_signals(LEVELS["min"], _model(tasks=tasks)) +check("min: M-задача не его класса → сигнала ready_task нет", + all(s[1] != "ready_task" for s in sigs_min), str(sigs_min)) + +# ── 4. Эскалации: адресованные УРОВНЮ будят его; чужие — нет (как в build_context) ── +escs = {"e-1": {"id": "e-1", "status": "open", "severity": "critical", + "to_level": "hour", "reason": "упал тик"}, + "e-2": {"id": "e-2", "status": "open", "severity": "warn", + "to_level": "hour", "reason": "warn-шум"}} +# hour: critical-эскалация адресована ему → сигнал critical +sigs_h = scheduler.collect_signals(LEVELS["hour"], _model(escs=escs)) +check("hour: critical-эскалация → сигнал critical", + any(s[0] == "critical" and s[1] == "escalation:e-1" for s in sigs_h), str(sigs_h)) +check("hour: warn-эскалация достаточно (порог warn)", + scheduler.gate_allows_llm(LEVELS["hour"], sigs_h) is True, str(sigs_h)) +# min: те же эскалации адресованы hour — минутному они не видны, гейт закрыт +sigs_min = scheduler.collect_signals(LEVELS["min"], _model(escs=escs)) +check("min: эскалации hour-адреса не будят минуту (сигналов нет)", + sigs_min == [], str(sigs_min)) +check("min: только warn-эскалация → гейт закрыт (порог critical)", + scheduler.gate_allows_llm(LEVELS["min"], sigs_min) is False) +# эскалация, адресованная МИНУТЕ (если бы сек умел эскалировать) — будит минуту +escs_min = {"e-6": {"id": "e-6", "status": "open", "severity": "critical", + "to_level": "min", "reason": "критично для минуты"}} +sigs = scheduler.collect_signals(LEVELS["min"], _model(escs=escs_min)) +check("min: critical-эскалация, адресованная min, будит минуту", + any(s[0] == "critical" and s[1] == "escalation:e-6" for s in sigs) + and scheduler.gate_allows_llm(LEVELS["min"], sigs) is True, str(sigs)) +# acked-эскалация не будит уровень +escs_acked = {"e-3": {"id": "e-3", "status": "acked", "severity": "critical", + "to_level": "hour", "reason": "закрыта"}} +check("hour: acked-эскалация не создаёт сигналов", + scheduler.collect_signals(LEVELS["hour"], _model(escs=escs_acked)) == []) +# эскалация, адресованная ВЫШЕ уровня, не его зона (min не видит hour→day) +escs_up = {"e-4": {"id": "e-4", "status": "open", "severity": "critical", + "to_level": "day", "reason": "дневная тема"}} +check("min: эскалация hour→day не адресована min", + scheduler.collect_signals(LEVELS["min"], _model(escs=escs_up)) == []) +check("day: эскалация hour→day видна day-уровню", + any(s[1] == "escalation:e-4" for s in + scheduler.collect_signals(LEVELS["day"], _model(escs=escs_up)))) + +# ── 5. Нагрузка: warn-сигнал, будит только уровни с порогом warn ── +sig_load = scheduler.collect_signals(LEVELS["hour"], _model(policy={"cpu_alert_threshold": 8}), load=9.5) +check("high_load: сигнал warn при load >= порога", + any(s[0] == "warn" and s[1] == "high_load" for s in sig_load), str(sig_load)) +check("min: high_load один не пробивает порог critical", + scheduler.gate_allows_llm( + LEVELS["min"], + scheduler.collect_signals(LEVELS["min"], _model(policy={"cpu_alert_threshold": 8}), load=9.5) + ) is False) + +# ── 6. Per-level порог из env: SIMBIOT_GATE_HOUR=critical / off ── +try: + os.environ["SIMBIOT_GATE_HOUR"] = "critical" + sigs = scheduler.collect_signals(LEVELS["hour"], _model(escs={ + "e-5": {"id": "e-5", "status": "open", "severity": "warn", + "to_level": "hour", "reason": "warn"}})) + check("env SIMBIOT_GATE_HOUR=critical: warn-эскалации мало", + scheduler.gate_allows_llm(LEVELS["hour"], sigs) is False, str(sigs)) + os.environ["SIMBIOT_GATE_HOUR"] = "off" + check("env SIMBIOT_GATE_HOUR=off: LLM всегда", + scheduler.gate_allows_llm(LEVELS["hour"], sigs) is True) +finally: + os.environ.pop("SIMBIOT_GATE_HOUR", None) + +# ── 7. Воркер: resolve_tool_budget per-level (e-260930060717/060904) ── +from harness import _llm_worker +check("tool budget: min=30 по умолчанию", + _llm_worker.resolve_tool_budget("min") == 30) +check("tool budget: hour=300 по умолчанию (просьба Alex «хоть 300»)", + _llm_worker.resolve_tool_budget("hour") == 300) +os.environ["SIMBIOT_MAX_TOOL_CALLS_HOUR"] = "100" +try: + check("tool budget: env SIMBIOT_MAX_TOOL_CALLS_HOUR=100 бьёт дефолт", + _llm_worker.resolve_tool_budget("hour") == 100) +finally: + os.environ.pop("SIMBIOT_MAX_TOOL_CALLS_HOUR", None) + +print() +print("FAILED: " + str(len(fails)) if fails else "ALL PASS") +sys.exit(1 if fails else 0) \ No newline at end of file