# Simbiot Harness Ритмичный агент: живёт тактами, как человек. CQRS + Event Sourcing на спине, Strands Harness SDK как исполнительное ядро, модели через 9router. ## Установка одной командой (t-261002193628-1d1e5f, как hermes) ```bash curl -fsSL https://raw.cil.by/simbiot/install.sh | bash ``` Что делает `scripts/install.sh`: клонирует репо в `~/simbiot-harness`, создаёт venv (Python 3.11+), ставит зависимости, кладёт обёртку `~/.local/bin/simbiot` (чистый env: unset PYTHONPATH/PYTHONHOME → `exec venv/bin/python cli.py`), тихо инициализирует стор и проверяет `simbiot status`. Флаги: `--dir DIR`, `--repo URL`, `--wizard` / `--no-wizard`, `--systemd` (юнит, только root), `-q` (тихо). Если в каталоге установки остался битый venv (python без pip — частый кейс WSL/старых клонов), скрипт пересоздаёт его и ставит зависимости (при недоступном ensurepip — системным pip3 в site-packages venv). В конце установки предлагается следующий шаг: портал https://cil.by — залогиниться и взять ключ API, затем `simbiot wizard --router-key <КЛЮЧ>`. Первый запуск — визард настройки (токен TG-бота, ключ 9router, модель): ```bash simbiot wizard # интерактивно simbiot wizard --noninteractive --router-key KEY --model auto ``` `.env` в корне репо читается стартапом (SIMBIOT_TG_TOKEN, SIMBIOT_TG_ALLOWED, ROUTER_KEY, SIMBIOT_MODEL и др.); в systemd-режиме `scripts/simbiot-harness.service` подхватывает его сам. ## Главная цель (фиксация Alex, 2026-09-30) Метрика харнеса — **максимизация долгосрочной AUM** (доход/ресурс всех проектов кооператива в длинном горизонте). Главный вопрос к любому уровню и любой задаче: «добавит ≥1% к AUM в горизонте год+?» — что приносит короткий доход, но съедает долгосрочную базу, приоритет ниже. На этой цели стоит автоприоритизация доски задач (t-260929161138; уточнение автоприоритизации день+час — t-260930153845, задача e-260930161330). Механика (t-260929161138, docs/aum.md): `cli.py aum --record` — снапшот метрики (AUM из docs/aum/balances.json + burn 9router за сутки/неделю) → событие `AumMeasured` → `policy.aum_target` → «ЦЕЛЬ (AUM)» инжектится в контекст каждого тика. Пилот петли оптимизации (симуляции + генетический отбор конфигураций burn, отчёт-адвокат): `cli.py aum-pilot` — песочница, без авто-применения. ## Криптоаудит (t-260930083716) Каждое событие подписывается при записи приватным ключом харнеса (ed25519, `.audit/ed25519.key` 0600), события сцеплены по `prev_sig` (тампер-цепочка); команды CLI — конверты `audit-command`/`verify-command`; проекции отвергают подделанный хвост, полный аудит — `cli.py verify` (выход 0/2), публичный ключ — `cli.py audit-key` / `.audit/ed25519.pub`. Подробно: docs/audit.md. ## Лестница (v0.1) | Уровень | Интервал | Контекст | Модель | Задачи | |---|---|---|---|---| | sec | 1 с | 0 (без LLM) | — | рефлекс: телеметрия, алерты | | min | 1 мин | 256K | auto (effort low) | S-триаж: входящие, быстрые фиксы | | hour | 1 ч | 512K | auto (medium) | S/M-задачи, дайджест часа | | day | 1 д | 1M | auto (high) | M/L-задачи, манифест дня | Конфиг согласован Алексом (e-261001082710): min 256k → hour 512k → day 1M (ctx_tokens в `harness/levels.py`); week/month/quarter/year — в roadmap v0.2. Принципы: тик идемпотентен (`UNIQUE(level, seq)`); занятый уровень поглощает тик (`TickAbsorbed` + детерминированная заглушка дайджеста, без LLM); запущенная задача не прерывается; наверх идут только дайджесты. ## События (ES-спина) `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, `TaskAwaitDelivery`, `DeliverySent`, `InitiativeSent`, `EscalationRaised`, `EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only, проекции пересобираются replay'ем, стейт = производное от событий. ## Структура - `harness/es.py` — event store + проекции (checkpoint + replay) - `harness/levels.py` — конфигурация уровней - `harness/scheduler.py` — демон лестницы - `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`) - `harness/commands.py` — command side - `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс) - `harness/paths.py` — карта персистентных путей (SIMBIOT_HOME) - `cli.py` — init/task/task-await/tasks/status/events/requeue ## Изоляция памяти инстанса — SIMBIOT_HOME (t-261004113351) Память агента — данные, не код: при переносе инстанса другому владельцу (мультитенантность) он не должен «думать» чужими проектами. Всё, что живёт дольше процесса — ES-база, RAG-индекс знаний, `.env`, ключи аудита, выжимки, штампы каналов, доки инстанса — размещается в одном каталоге-хоме, репо остаётся чистым кодом. Свежие установки: `~/.simbiot-data` (install.sh проставляет в обёртку и systemd-юнит автоматически). ``` SIMBIOT_HOME=/path/to/home venv/bin/python cli.py status ``` | файл | назначение | |---|---| | `/db/harness.db` | ES-база (события, проекции, delivery-леджер) | | `/rag/rag.db` | RAG-индекс знаний инстанса | | `/env/.env` | секреты (токены, ключи роутера), 0600 | | `/docs/` | проектные документы владельца (aum/balances.json) | | `/digests/` | файлы-выжимки уровней | | `/audit/` | ключи крипто-аудита ed25519 | | `/inbox/`, `/run/`, `/voice/` | каналы, штампы, кэш ASR | Приоритет: точечная env (`SIMBIOT_DB`, `SIMBIOT_RAG_DB`, …) → `SIMBIOT_HOME` → legacy-пути из корня репо (обратная совместимость: не задан `SIMBIOT_HOME` — всё работает по-старому). Новый инстанс (например, для Юры): `scripts/init_instance.py` — чистая база + идентичность владельца + его доки (+RAG-индексация) + опциональный селективный импорт выжимок/доков из инстанса-источника. Тест изоляции: `tests/test_instance_isolation.py` (поднять копию с пустой памятью — контекст тика и RAG не содержат чужих проектов, 14 проверок). Перенос существующего инстанса в хом: `scripts/migrate_to_home.sh` (ждёт тихое окно без LLM-воркеров, идемпотентен). ## CLI ```bash ./venv/bin/python cli.py init --identity "…" ./venv/bin/python cli.py task "…" -m 240 # -m minutes-to-live estimate: S/M/L ./venv/bin/python cli.py task "…" -a # автодоставка результата в TG при готовности ./venv/bin/python cli.py task-await # пометить существующую: доставить в TG ./venv/bin/python cli.py tasks ./venv/bin/python cli.py status ./venv/bin/python cli.py events -n 50 ./venv/bin/python cli.py requeue -r "причина" ./venv/bin/python cli.py promote -r "вне класса уровня" # S→M→L ./venv/bin/python cli.py escalate "причина" -f min -c "контекст" -s critical ./venv/bin/python cli.py escalations --open # открытые эскалации ./venv/bin/python cli.py escalation-ack -n "что сделал" # закрыть ./venv/bin/python cli.py tg-reply "текст" # ответ на входящее TG # батч-режим (t-260930064924): один сводный ответ закрывает несколько входящих ./venv/bin/python cli.py tg-reply m-1,m-2,m-3 -c "текст" # ReplySent на каждое, одна отправка # идемпотентные уведомления/файлы (t-260930083937): повтор — SKIP, дублей в чат нет ./venv/bin/python cli.py tg-notify "текст" -k <ключ> ./venv/bin/python cli.py tg-deliver-file <путь> -c "подпись" [--force] ``` ## Автодоставка артефактов (t-260929205927) Задача, заказанная из TG (`cli.py task -a` / `task-await`), помечается `TaskAwaitDelivery`. При завершении (`TaskCompleted`) результат (answer) уходит в чат сам — `DeliverySent`, без повторного запроса. Push в тике (сразу после завершения) + pull-страховка (delivery-демон каждые 15с: досылает недоставленное, если тик умер между завершением и отправкой). Идемпотентно: один раз доставлено — повторно не шлётся; после requeue доработка доставляется снова. Формат: «✅ Готово: » + выжимка ≤1800с. ## Идемпотентность доставки (t-260930083937) Инцидент: Alex получал презентацию трижды (m-516173239/241) — гонка push (тик) vs pull (демон): оба проходили проверку `delivery_status != 'sent'`, пока TG-отправка (до 35с) была в полёте; плюс ad-hoc curl sendDocument без метки delivered. Фикс — три атомарных барьера в `deliver_result` / `deliver_artifact`: 1. **ledger** (`delivery_ledger`, INSERT OR IGNORE): факт «доставлено раз и навсегда» по артефакт-идентификатору (задача/файл/ключ сообщения). Повтор → SKIP + событие `DeliverySkipped` (пропуск логируется); 2. **claim** (`delivery_locks`, атомарный INSERT…WHERE NOT EXISTS): пометка «доставляю» ДО отправки, TTL 120с > таймаута TG. Параллельный уровень/демон получает False и пропускает — гонка закрыта на уровне SQLite (межпроцессно, WAL). Сбой отправки → release, демон досыплет; 3. **запись в ledger** ПОСЛЕ успеха + `DeliverySent`. Requeue сбрасывает ledger — доработка доставляется заново. Файлы в чат — только через `cli.py tg-deliver-file` (тот же барьер); прямые curl/api.telegram отправки из тиков запрещены (мандаты уровней). Тесты: `tests/test_delivery_idempotency.py` (49 проверок: гонки, сбои, requeue, межпроцессный claim, CLI). ## Эскалации Важное с нижних уровней поднимается наверх событием `EscalationRaised` (min→hour→day; day — вершина, эскалировать некуда). Открытые эскалации инжектятся в контекст верхних уровней (`build_context`) в приоритетной секции — нижний уровень не тонет в чужих проблемах, верхний видит выжимку, а не сырь. Уровень-адресат закрывает эскалацию `escalation-ack` (событие `EscalationAcknowledged`). Падение LLM-тика (`CycleError`) эскалируется автоматически — scheduler поднимает warn-эскалацию на уровень выше. ## Запуск ```bash systemctl enable --now simbiot-harness journalctl -u simbiot-harness -f ``` ## Telegram-инбокс Бот @juno42bot → long polling (`simbiot-harness-tg.service`) → `MessageReceived` в стор → минутный уровень видит входящие в контексте → отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее). Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`. Батч-триаж (t-260930064924): минутный тик видит ВСЕ неотвеченные сообщения как один батч (секция инбокса в контексте, кап 50) и обязан закрыть их за ОДИН тик — сводным ответом `cli.py tg-reply <id1>,<id2>,… -c "текст"` (одна TG-отправка, `ReplySent` на каждое входящее; полный текст хранится у головного, у хвостовых — ссылка) или отдельными реплаями за тот же тик. Гейт важности после сводного ответа больше не будит минуту на «хвосты». Раньше m-516173222..228 обрабатывались 7 тиками подряд — по одному за тик. ## Инициативные реплики в чат (t-260930073419) Харнес не только отвечает, но и сам пишет в чат: периодически — мысль/идею по общему интересу (рост AUM кооператива) с вопросом-крючком. Механизм (`harness/proactive.py`) без LLM: темы — детерминированный банк, ноль кредитов роутера, предсказуемый тон. - Событие `InitiativeSent` (aggregate `cycle`, id `proactive:<slot>`) — идемпотентно через UNIQUE стора; проекция хранит последний слот + историю (анти-спам стейт). - Ритм: слот = 8-часовые коряги в окне активности 5–21 UTC (по истории чата: пики 5–8 и 15–21, ночь — молчим); не чаще раза в 8ч; тишина 2ч после последнего сообщения Alex (не перебиваем разговор); не пишем при неотвеченных входящих и открытых critical-эскалациях. - Темы round-robin: наименее использованная из банка (10 идей про AUM — формула капитала, места 42 Labs, расходы роутера, воронка портала, проекты, тарифы, отчёт о пользе); ответ Alex подхватывает минутный уровень как обычное входящее. - Запускается из delivery-демона (раз в 15с, best-effort), гейт важности не затрагивается: LLM-тики остаются тихими. - env-оверрайды: `SIMBIOT_PROACTIVE_INTERVAL_H`, `SIMBIOT_PROACTIVE_QUIET_H`, `SIMBIOT_PROACTIVE_SLOT_H`. ```bash ./venv/bin/python cli.py proactive # отправить, если ритм позволяет (SKIP + план, если нет) ./venv/bin/python cli.py proactive --plan # состояние ритма без отправки ./venv/bin/python cli.py proactive --force # сейчас, игнорируя окно/тишину (демо) ``` ## Без-LLM команды бота (t-260929210755) `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит, `/cmd@bot`, одно слово без слэша) понимает парсер; список в чате — `setMyCommands` (кириллица, фолбэк — транслит), регистрируется стартом tg-поллера или вручную: ```bash ./venv/bin/python cli.py tg-commands --register # setMyCommands ./venv/bin/python cli.py tg-commands --list # что у бота сейчас ``` События: `CommandHandled` (команда закрыта рефлексом) + `ReplySent`; `build_context` не показывает команды мин-уровню — даже без ответа (гонка на границе тика / лежащий TG не заставят минуту тратить LLM). ## RAG в тике (t-260929212316) Каждый LLM-тик получает инструмент `rag_lookup` — семантический поиск по базе знаний кооператива (вендор `/root/rag`: fastembed bge-small + sqlite, rag.db ~12.5k чанков по контексту/навыкам, hermes.db ~54k). Лимиты зашиты в инструмент, не в промпт: **1 запрос на тик**, k<=5, таймаут 20с, сабпроцесс `rag-venv` (ONNX-модель не грузится в процесс воркера), упавший вендор не валит тик. Трейс — `RagQueried` в сторе. Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`. Выключить: `RAG_DISABLED=1`. ## MCP портала в тике (t-261010093054) Каждый LLM-тик получает инструмент `portal_mcp` — живой портал кооператива через MCP-шлюз cil.by (JSON-RPC 2.0, агент зарегистрирован 29.09, владелец alex). Ключевое для Alex: **звонки назначаются из чата** — `booking_slots` (свободные слоты, call=30мин/session=20мин, график 3/3 Минск) → `booking_create` (jitsi-звонок со ссылкой, гостю письмо, запись и транскрипция автоматом). Остальной спектр: list_calls/get_call (саммари прошедших), list_members/projects_list/list_agents, treasury_status, list_events, whoami/report_task/report_event. Лимиты зашиты в код (не в промпт): **4 вызова на тик**, таймаут 15с, обрезка результата 6k симв.; тул не поднимается без токена. Guardrail: деньги-тулы `product_create`/`product_set_mode` (Stripe) запрещены по умолчанию — `SIMBIOT_MCP_ALLOW_ALL=1` снимает. Ошибки шлюза возвращаются текстом `error: …`, тик не валится; трейс — `PortalMcpCalled` в сторе. Токен: env `SIMBIOT_PORTAL_MCP_TOKEN` → `<home>/secrets/portal-mcp-token` (legacy-фолбэк `.simbiot-portal-token` в корне репо). Ручной вызов: `cli.py mcp-call booking_slots '{"type": "call"}'`, список тулов — `cli.py mcp-call --list`. Клиент: `harness/portal_mcp.py`, обёртка тика — `_llm_worker._portal_mcp_tool_for`, тест `tests/test_portal_mcp.py`. ## Хард-кап шагов тика (e-260930031800) Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал по таймауту 300с — CycleError, эскалации min→hour каждую ~50 минут. Защита в `_llm_worker`: `BeforeToolCallEvent.cancel_tool` отменяет вызова сверх лимита, `AfterToolsEvent.end_turn` останавливает цикл агента ПОСЛЕ партии инструментов (без него модель шлёт шаги снова — e2e: 20 отмен подряд), кладя в историю ассистентский текст-итог. Нативный `limits={"turns": N}` НЕ используется: стоп на границе хода оставляет `result.message` = toolResult → пустой дайджест (04:18+). Лимит: `SIMBIOT_MAX_TOOL_CALLS` (по умолчанию 30, счёт по реальным исполнениям). ## Дашборд задач Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`). Running-задача подсвечена бейджем «🔧 в работе: <уровень> · <elapsed>» (t-261001082111): время взятия — `claimed_at` из проекции TaskClaimed, таймер живёт в браузере; requeue сбрасывает `claimed_at`. ```bash systemctl status simbiot-dashboard # порт 8765, healthcheck 8766 TOKEN=$(grep -oP '(?<=SIMBIOT_DASH_TOKEN=).*' /etc/simbiot/dashboard.env) xdg-open "http://<host>:8765/?token=$TOKEN" # токен пишется в cookie curl "http://127.0.0.1:8765/api/state?token=$TOKEN" | jq . # машиночитаемо ``` Реализация: `harness/dashboard.py` (stdlib-only http.server, read-only из event store через `run_projections()` — консистентно с scheduler/CLI) + `harness/dashboard.html`. Авторизация: `SIMBIOT_DASH_TOKEN` из `/etc/simbiot/dashboard.env` (env-файл юнита, режим 600); без токена — 401. UFW: порт 8765/tcp открыт (токен-защищён). ## Модели 9router (router.cil.by), отдельный ключ `sk-simbiot` в `.env`, комбо `auto`: glm-5.3 → kimi-k3 → deepseek-v4-pro → v4.1-flash → flash. Бюджет виден в событиях `BudgetSpent` (по уровням) и в дашборде 9router по ключу. ## v0.2 (roadmap) - прерывание срочной реакцией / пауза (сейчас absorbed детерминирован) - уровни week/month/quarter/year - инбокс из Telegram/портала