simbiot-harness/README.md

286 lines
No EOL
19 KiB
Markdown
Raw 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
Ритмичный агент: живёт тактами, как человек. 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 команды бота (сек-рефлекс)
- `cli.py` — init/task/task-await/tasks/status/events/requeue
## 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 <task-id> # пометить существующую: доставить в 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 <task-id> -r "причина"
./venv/bin/python cli.py promote <task-id> -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 <esc-id> -n "что сделал" # закрыть
./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее 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
доработка доставляется снова. Формат: «✅ Готово: <title>» + выжимка ≤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`.
## Хард-кап шагов тика (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/портала