simbiot-harness/README.md
Simbiot Harness 2d70740e76 fix(worker): убрать limits.turns — стоп без текста, дайджесты пустели
Репродукция: мин-тик с реальным контекстом → модель (auto/low effort)
каждый ход делала tool-вызов, никогда не выдавая чистый текст;
limits={'turns': 15} обрывал цикл на границе хода, result.message
оставался toolResult-сообщением → str(result)='' → DigestWritten len 0.

limit_turns — нативный стоп SDK, но он не кладёт в историю итоговый
ассистентский текст. Наш end_turn (AfterToolsEvent) кладёт: дайджест
не пустеет. Защита от таймаутов остаётся двойной: cancel_tool на
вызовы сверх лимита + end_turn после партии.

E2E-подтверждение: тот же промпт, stop_reason=end_turn, текст 465
символов ('Тик чистый. Проверил: ...').
2026-09-30 04:24:58 +00:00

160 lines
No EOL
9.7 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.
## Лестница (v0.1)
| Уровень | Интервал | Контекст | Модель | Задачи |
|---|---|---|---|---|
| sec | 1 с | 0 (без LLM) | — | рефлекс: телеметрия, алерты |
| min | 1 мин | ~4K | auto→flash | S-триаж: входящие, быстрые фиксы |
| hour | 1 ч | ~32K | auto | S/M-задачи |
| day | 1 д | ~128K | auto | M/L-задачи, манифест дня |
Принципы: тик идемпотентен (`UNIQUE(level, seq)`); занятый уровень поглощает
тик (`TickAbsorbed` + детерминированная заглушка дайджеста, без LLM);
запущенная задача не прерывается; наверх идут только дайджесты.
## События (ES-спина)
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
`TaskAwaitDelivery`, `DeliverySent`, `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-260929205927)
Задача, заказанная из TG (`cli.py task -a` / `task-await`), помечается
`TaskAwaitDelivery`. При завершении (`TaskCompleted`) результат (answer)
уходит в чат сам — `DeliverySent`, без повторного запроса. Push в тике
(сразу после завершения) + pull-страховка (delivery-демон каждые 15с:
досылает недоставленное, если тик умер между завершением и отправкой).
Идемпотентно: один раз доставлено — повторно не шлётся; после requeue
доработка доставляется снова. Формат: «✅ Готово: <title>» + выжимка ≤1800с.
## Эскалации
Важное с нижних уровней поднимается наверх событием `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`.
## Без-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`).
```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/портала