simbiot-harness/docs/ARCHITECTURE.md

36 KiB
Raw Blame History

Архитектура Simbiot Harness (Коди)

17.10.2026 · HTML: docs/ARCHITECTURE.html (собирается scripts/build-architecture-html.py) · онлайн-версия для комментариев: https://claude.ai/code/artifact/195fd476-44e7-47cc-9093-2d41ea53c8c9

Обзор

Simbiot Harness — ритмичный агент кооператива: он живёт тактами (секунда, минута, час, день), а в Telegram отвечает как Коди (@juno42bot). Главная метрика — долгосрочный AUM кооператива: каждое решение проверяется вопросом «добавит ли это ≥1% к AUM на горизонте год+».

Архитектура держится на пяти принципах:

  • Всё — события. Единственный источник правды — append-only стор событий; состояние (read model) — проекция. Каждое событие подписано ed25519 и сцеплено с предыдущим.
  • Дешёвое решает раньше дорогого. Детерминированные правила и локальные/облачные decision-модели отсекают лишнее до запуска LLM: гейт важности, фильтр адресации, выбор глубины памяти.
  • LLM — в изоляции. Каждый LLM-тик — отдельный процесс с бюджетом шагов, таймаутами и стражем прав на каждом вызове инструмента.
  • Доверие — по личности, не по тексту. Права тика определяет самый недоверенный вход его контекста.
  • Учится на исходах. Ядро Коди собирает решения и их последствия, обучает маленьких учеников и допускает их в бой только при честной победе над базовой линией.

Куб 4×4×4 задаёт поведение одной таблицей политик: 4 круга доверия × 4 тика × 4 глубины памяти. Это не 64 программы, а один код и параметры.

Telegram ──► TG-поллер ──► Адресация ◄┄┄ Decision-модель (clef:27b → nimble)
Портал/агенты ──────────►     │
                              ▼
                         Гейт важности
                              ▼
                     Круг доверия и память ◄┄┄ Память и ядро (индекс nomic, ученики)
                              ▼
Человек (cli, TUI)      LLM-воркер ──────► 9router и портал MCP
        │                     │
        ▼                     ▼
   Стор событий: подписи ed25519 · проекции · сжатие с архивом · каждый шаг пишет события

Процессы и сервисы

Харнес — два долгоживущих процесса на одном сторе событий плюс короткоживущие LLM-воркеры; код — /root/simbiot-harness, данные инстанса — /var/lib/simbiot-harness (SIMBIOT_HOME).

Процесс Запуск Что делает
Лестница simbiot-harness.service → python -m harness.scheduler такты sec/min/hour/day в отдельных потоках, демон доставки и сирот, триаж, ночная тренировка ядра
TG-поллер simbiot-harness-tg.service → python -m harness.tg_inbox long polling @juno42bot: входящие, файлы, расшифровка голоса, кнопки
LLM-воркер harness/_llm_worker.py, сабпроцесс на каждый LLM-тик агент Strands с инструментами, страж прав, бюджет шагов
Дашборд simbiot-dashboard.service, HTTP 8765 доска задач и тиков, только чтение стора
CLI cli.py (и обёртка simbiot) команды агентов и людей: ответы, задачи, эскалации, ядро, память, доверие

Каталоги инстанса (/var/lib/simbiot-harness):

Путь Содержимое
db/harness.db стор событий + read model + журнал доставок (SQLite, WAL)
env/.env секреты и флаги (600)
audit/ ключ ed25519 харнеса
archive/ сжатые события (jsonl.gz, подписаны)
core/ чемпионы учеников ядра + кеш эмбеддингов
memory/ индекс памяти Коди
inbox/ файлы из чатов

Внешние зависимости:

Система Зачем
9router (127.0.0.1:20128) LLM для тиков, модель auto
Ollama Cloud decision-модель clef:27b
Ollama локально (127.0.0.1:11434) запасная decision-модель nimble, эмбеддинги nomic-embed-text
Портал cil.by (MCP) звонки, реестры, канал агент↔агент
Telegram Bot API чаты Коди
Deepgram расшифровка голосовых
RAG /root/rag поиск по базе знаний кооператива

Event Sourcing и криптоаудит

Всё состояние харнеса выводится из подписанной цепочки событий (harness/es.py, harness/audit.py); после сжатия 17.10 стор — 200 MB, полная проверка — около 30 с.

Стор. Таблица events (aggregate, aggregate_id, event_type, data, timestamp, version, sig, key_id, prev_sig). Уникальность (aggregate, aggregate_id, version) делает запись идемпотентной: тик level:seq не произойдёт дважды. Клейм задачи — атомарный (claim_task_if_ready, BEGIN IMMEDIATE), поэтому два уровня не возьмут одну задачу.

Проекции. run_projections() инкрементально строит read model (задачи, инбокс, эскалации, дайджесты, круги доверия, политика AUM) и сохраняет снапшот в read_model. Перед применением хвост проверяется подписями: подделка останавливает проекции.

Подписи. Каждое событие подписано ключом харнеса (ed25519, key_id faf8d19d9aed71fe), prev_sig ссылается на подпись более раннего. События до внедрения подписей помечены genesis:<id>, неподписанные — legacy-границы. Защита от чужого ключа: стор, уже подписанный одним ключом, другим не подписывается. Полная проверка — cli.py verify.

Сжатие с подписью (harness/compact.py, scripts/compact-store.sh). Шум сек-уровня старше часа уходит в архив:

  1. Строки целиком, со всеми подписями, пишутся в archive/events-<first>-<last>-<id>.jsonl.gz (600).
  2. Подписи удалённых строк, на которые ссылаются остающиеся, и маркеры boundary:<id> для legacy-границ попадают в мост compaction_bridge.
  3. Подписанное событие StoreCompacted фиксирует sha256 архива, строк и моста.
  4. Удаление идёт пачками по 20 000 строк без остановки сервисов; VACUUM — под остановленными писателями (секунды).

verify принимает мост, только если его хеш совпадает с подписанным StoreCompacted; архивы проверяются построчно (cli.py compact --verify-archives). Первое сжатие: 2 886 765 строк в архив (246 MB), 1389 → 200 MB.

Сек-уровень больше не шумит: CycleTicked/ReflexObserved пишутся heartbeat'ом раз в 60 с и в такты с событием (алерт, команды, каналы).

Тактовая лестница

Четыре уровня тикают независимо, каждый в своём потоке (harness/scheduler.py, harness/levels.py); дорогой LLM-тик запускается, только если есть событие важнее порога уровня.

Уровень Интервал LLM Порог гейта Задачи Шагов на тик Таймаут Память по умолчанию
sec 1 с нет — — — — —
min 1 мин + по входящему effort low, контекст 256k critical S 30 480 с D1
hour 1 ч effort medium, 512k warn S, M, L 300 2880 с D2
day 1 д effort high, 1M warn S, M, L 10 000 таймаут уровня D3

sec — рефлекс без LLM. Телеметрия и алерт нагрузки, мгновенные команды бота (/лестница, /статус, /задачи), каналы портала и агент↔агент.

Гейт важности. Сигналы: входящее без ответа и готовая задача — critical; эскалации — по их severity; высокая нагрузка — warn. Ниже порога тик закрывается детерминированно (GateSkipped + заглушка-дайджест). Серая зона (только warn/info) спрашивает decision-модель — сейчас в режиме shadow.

Тик по входящему. Демон раз в секунду смотрит хвост стора; через 3 с тишины после MessageReceived или MessageTranscribed запускает внеочередной мин-тик в слот следующей минуты. Ответ в Telegram приходит за 10–40 с вместо ~80 с медианы до нуджа.

Батч задач. Уровень берёт все ready-задачи своих классов в бюджете контекста: из 9 старейших первой ставится самая ценная для AUM (decision-модель), остальные — FIFO. В один батч попадают задачи одного круга доверия, и уровень берёт только обслуживаемые круги.

Эскалация недоделанных. «Бюджет исчерпан» или смерть воркера возвращают задачу в очередь; после 2 захватов на уровне она растёт до размера, которого этот уровень не берёт (S → M, к часу). До этого S-задача могла браться минутным уровнем 33 раза подряд. Задачи про внешние системы (портал, сервер, деплой, UI, git) сразу уходят часу — правило головы task_fit.

Занятый уровень поглощает тик (TickAbsorbed), запущенная задача не прерывается, наверх идут только дайджесты и эскалации (cli.py escalate). Демон доставки возвращает задачи-сироты после рестартов и доставляет результаты в чат идемпотентно (журнал доставок).

LLM-воркер

Каждый LLM-тик — отдельный процесс (isolated_runner.py → _llm_worker.py) со свежим asyncio-рантаймом Strands: падение или таймаут не роняют лестницу, а вся группа процессов добивается по таймауту уровня.

Вход воркера (payload): мандат уровня, собранный контекст тика, модель и effort, флаг shell, круг доверия тика. Результат возвращается JSON-файлом; трассу (InvocationStarted, ToolCalled, ToolResult, BudgetSpent) воркер пишет в стор сам и инкрементально, чтобы таймаут не съел учёт расходов.

Инструменты:

Инструмент Ограничение
shell, read, write, edit только уровни с can_shell; в кругах ≥ 2 — страж прав
rag_lookup 1 запрос на тик, k ≤ 5, таймаут 20 с
portal_mcp 4 вызова на тик, деньги-тулы запрещены по умолчанию
cli.py … через shell основной способ действовать: tg-reply, task, complete, escalate, tg-deliver-file, decide

Хуки на каждом вызове (BeforeToolCallEvent, до исполнения):

  1. Страж прав — trust.check_tool: запрещённый вызов отменяется (cancel_tool) и пишется TrustDenied.
  2. Бюджет шагов — сверх лимита уровня вызовы отменяются, цикл завершается итоговым текстом; задачи без complete возвращаются или эскалируются.
  3. Трасса — ToolCalled/ToolResult с trace_id, уровнем и длительностью.

TTFT-сторож. Если апстрим 9router принял запрос, но за 45 с не прислал первый токен, стрим закрывается и делается новый вызов (до 3 попыток). Начавший отвечать стрим не трогается.

Окружение. SIMBIOT_TICK_CIRCLE наследуют shell и cli.py: задачи из тика штампуются его кругом, а cli.py trust set из тика отказывает.

Telegram-канал

Коди в Telegram — бот @juno42bot (id 7245454391); поллер пишет входящие в стор, а отвечает минутный (реже часовой) тик через cli.py tg-reply.

Приём (harness/tg_inbox.py). Long polling (message, edited_message, callback_query). Отправитель вне SIMBIOT_TG_ALLOWED → MessageIgnored(not_allowed). Документы и фото сразу сохраняются на диск, голосовые расшифровываются. Сохраняется, на чьё сообщение сделан reply и упомянут ли бот. Правка уже принятого сообщения не становится новым входящим.

Адресация в группах (harness/addressing.py) — до гейта, без LLM:

  1. Личка, портал, агенты — всегда адресовано.
  2. В группе «да» без модели: @juno42bot, reply на сообщение бота, звательное обращение («Коди, …», «…, Коди», «Коди!»).
  3. «Нет» без модели: reply на сообщение другого человека.
  4. Серая зона — decision-модель: «кому адресовано — боту, человеку или группе», P(бот) ≥ 0.5. Разговор О Коди в третьем лице обращением не считается. Модель недоступна — бот молчит.

Вердикт: MessageTriaged{addressed} + 👀 или MessageIgnored(not_addressed) — тогда ни один уровень его не видит. В тени рядом работает ученик ядра addressing.

Ответы. В группе — reply'ем на последнее сообщение батча (allow_sending_without_reply), в личке — без цитаты. Сводный ответ на несколько сообщений — tg-reply m-1,m-2 -c. Правило «сначала ответ, потом работа»: если нужно больше ~30 с, сначала короткий ответ, итог — tg-reply <id> --followup в тот же чат (идемпотентно по тексту).

Реакции. 👀 ставится при приёме в личке и триажем в группе — только на адресованное боту. Параметр Bot API — reaction (массив); до 17.10 ошибка в имени параметра делала реакцию невидимой.

Журнал исходящих. Каждое сообщение бота → OutboundMessage (chat:message_id → полный текст). Reply на сообщение бота получает reply_to_full (до 4000 символов вместо 300 от Telegram), а тик видит блок «ЭТО ОТВЕТ НА ТВОЁ СООБЩЕНИЕ». Собственные рассылки бота за 48 ч попадают в память тика.

Кнопки. core:… — «Это было мне?» для ученика ядра; trust:… — «Разрешить полные права», нажать может только владелец.

Decision-модель

Решения «выбрать один из нескольких» и «да/нет» принимает не LLM, а decision-модель Ollama /v1/systemone (harness/decision.py): облачная clef:27b отвечает за 0.6–0.9 с, запасная локальная nimble (qwen3.5-9B Q8_0, CPU) — за 10–30 с.

Контракт. choice — варианты объектом {ключ: описание}, ответ — ключ и распределение вероятностей; noul — P(«да»). Шортлист строго ≤ 9 вариантов: по замерам systemone-bench точность падает со 100% на 6 до 50% на 10. confidence — концентрация распределения, а не вероятность правильности.

Надёжность. Бэкенды опрашиваются по порядку, у каждого свой предохранитель: облако — таймаут 30 с, после сбоя пауза 60 с; локальная — 90 с и 5 минут. Ответы детерминированы, поэтому кешируются на час по хешу вопроса и состояния. Каждый реальный вызов → DecisionMade (бэкенд, модель, латентность, применено ли). Модель недоступна — вызывающий берёт свой детерминированный фолбэк.

Потребитель Вопрос Режим Фолбэк
адресация в группах кому адресовано сообщение on молчать
порядок батча задач какая из 9 старейших ценнее для AUM on FIFO
тема инициативы что сейчас полезнее предложить on по кругу
серая зона гейта нужен ли дорогой LLM-тик shadow порог
агент в тике cli.py decide -q … -o ключ=описание on —

Режимы: SIMBIOT_DECISION=off|shadow|on, точечно SIMBIOT_DECISION_<ПОТРЕБИТЕЛЬ>; ключ облака — SIMBIOT_DECISION_API_KEY в .env инстанса. Статус и статистика — cli.py decide, cli.py decide --stats.

Ядро Коди

Ядро (harness/kodi_core.py) — цикл самообучения по образцу systemone-bench (engine42/router42): учитель и правила решают, маленький ученик учится на их решениях и реальных исходах и получает право решать сам только после честной проверки. Сейчас у ядра три головы.

Голова Решение Метки Режим Состояние на 17.10
addressing отвечать ли в группе учитель (вес 1), реакция чата (3), кнопки владельца (5) shadow чемпион на 262 примерах
task_fit влезет ли S-задача в мин-тик исходы задач из истории on (правило «внешние системы») ML-чемпион снят: не бил базу; действует правило (точность 0.74)
depth нужна ли глубокая память решения правил (1), нехватка памяти по трассе (3) shadow копит данные, нужно 40 примеров

Ученик. Признаки — эмбеддинг nomic-embed-text (локально, ~70 мс, с дисковым кешем) плюс флаги головы. Модель — логистическая регрессия на numpy (Adam, L2): на сотнях примеров она устойчивее MLP. kNN-память даёт «новизну»; её пороги калибруются при обучении.

Роутер. Знакомая ситуация и |p − 0.5| ≥ 0.4 → решает ученик; незнакомая и учитель сомневается → вопрос владельцу кнопками (≤ 5 в сутки); иначе — учитель. В shadow ученик только пишет CorePrediction.

Честный допуск (ночная тренировка в дневной тик, cli.py core train):

  1. Чемпион помнит trained_upto; с претендентом сравнивается только на свежих данных (≥ 10), которых он не видел.
  2. Претендент обязан бить базовую линию — константу, равную доле класса.
  3. Чемпион, который на свежих данных хуже базы, снимается (CoreModelRetired) — ученик молчит.
  4. Смена чемпиона — подписанное CoreModelPromoted с sha256 файла; подменённый файл не грузится; откат — cli.py core rollback --head <голова>.

Рефлексия. Раз в сутки: ≥ 3 расхождений адресации с метками человека/чата или ≥ 3 S-задач, не влезших в мин-тик, → задача часу с автодоставкой: разобрать случаи и предложить владельцу правило. Первая рефлексия (17.10) привела к правилу task_fit «внешние системы → сразу к часу».

Недельный отчёт. По понедельникам владельцу в личку, без LLM, идемпотентно по неделе (cli.py core report).

Куб 4×4×4: круги доверия

Поведение задаёт одна таблица POLICY (harness/trust.py): круг доверия определяет инструменты, уровни времени и потолок глубины памяти. Вторая ось — тики (sec/min/hour/day), третья — глубина D0–D3.

Круг Кто Shell Файлы Портал MCP Уровни Память до
1 владелец Alex, локальный терминал, система (тик без внешних входов) полный полный доступ всё (деньги — по флагу) все D3
2 правление Vlad, Yura cli.py: ответы, задачи, эскалации, доставка файлов, обзор доски чтение docs/out/sites/artifacts и присланного звонки, реестры, фичи; без казны и member_create min, hour, day D3
3 свои участники портала, MCP-агенты, партнёры cli.py: ответы, задачи, эскалации нет запись на звонок, проекты, фичи min, hour D2
4 остальные прочие Telegram-пользователи cli.py: только ответ и эскалация нет нет min D1

Как определяется круг. Только по проверенной личности: Telegram user_id, канал MCP-агента или портала, локальный терминал. Текст сообщения прав не даёт. Меняет круг только человек на сервере (cli.py trust set <субъект> <1-4>) — из LLM-тика это запрещено и стражем, и самим CLI.

Круг тика = самый недоверенный вход его контекста (входящие + задачи батча). Задачи наследуют круг тика, в котором поданы: просьба правления не исполняется позже часовым уровнем с правами владельца.

Страж проверяет каждый вызов инструмента до исполнения. Shell в кругах 2–4 — одна команда cli.py из белого списка круга, без ; && | > $() и обратных кавычек; файлы с секретами закрыты всегда. Задача правления упёрлась в права → владельцу приходят кнопки «Разрешить / Нет»; «Разрешить» даёт задаче круг 1.

Вживую проверено на реальном воркере в круге 2: cat /etc/hostname отменён до исполнения, cli.py decide --stats выполнился.

Динамическая память D0–D3

Перед каждым LLM-тиком харнес выбирает, сколько собственной истории подгрузить (harness/memory.py, SIMBIOT_MEMORY=on); индекс на 17.10 — 1650 разговоров, 270 итогов задач, 91 дайджест.

Глубина Что в контексте Когда
D0 только текущие входящие все реплики односложные: «ок», «спасибо»
D1 + 12 последних реплик чата + свои сообщения за 48 ч минутный тик по умолчанию
D2 + до 8 эпизодов из своей истории по смыслу (≤ 5000 символов) час; «помнишь», «вчера», «как договаривались»; задачи в батче; reply на сообщение бота старше 48 ч
D3 + до 15 записей архива (≤ 12 000 символов) день; «за месяц», «что ты делал», «итоги», «план на»

Потолок задаёт круг: «свои» — до D2, «остальные» — до D1.

Индекс (<home>/memory/index.sqlite) инкрементально догоняет стор перед поиском (~3.5 с, только чтение):

  • conv — сообщение + ответ Коди (в батче — ответ головы);
  • out — собственные рассылки бота, не ответы;
  • task — задача и её итог;
  • digest — содержательные дайджесты часа и дня (заглушки отсеиваются).

Приватность. У каждой записи — круг: сказанное в чате видно всем его участникам, поэтому круг записи = самый недоверенный участник чата (личка владельца — 1, «Правление» — 2); задача — её круг; дайджест — 1. Поиск отдаёт только записи с кругом ≥ круга тика: тик с сообщением правления не поднимет личку владельца. Круги читаются прямо из событий TrustSet, чтобы отставшая проекция не дала записи слишком широкий круг.

Релевантность. Эмбеддинги nomic-embed-text (search_document / search_query). Калибровка на прод-индексе: релевантное — 0.78–0.88, посторонний запрос — до 0.75. Порог 0.76 и окно 0.07 от лучшего совпадения; свежий разговор того же чата (уже в D1) не дублируется.

Обучение выбора. Решение о глубине → DepthChosen с причинами и признаками нехватки памяти по трассе: агент полез в базу знаний или в свой стор, ответил «не помню». Это сырьё для головы ядра depth.

Конфигурация, CLI, деплой и тесты

Настройки читаются из env процесса, затем из /var/lib/simbiot-harness/env/.env; новые подсистемы по умолчанию выключены или в shadow, а на проде включаются строкой в .env.

Переменная Прод Что делает
SIMBIOT_DECISION on decision-модель (гейт — shadow)
SIMBIOT_DECISION_API_KEY задан облако clef:27b, запасная — локальная nimble
SIMBIOT_MEMORY on индекс памяти и блоки D2/D3
SIMBIOT_CORE_ADDRESSING shadow ученик адресации
SIMBIOT_CORE_TASK_FIT on сразу к часу задачи про внешние системы
SIMBIOT_CORE_DEPTH shadow ученик глубины памяти
SIMBIOT_TG_ALLOWED Alex, Yura, Vlad кто вообще обрабатывается
SIMBIOT_MIN_NUDGE on тик по входящему
SIMBIOT_TASK_ATTEMPTS_PER_LEVEL 2 попыток до эскалации размера
SIMBIOT_MAX_TOOL_CALLS_<УРОВЕНЬ> min 30, hour 300 бюджет шагов тика

CLI (cli.py, для людей и агентов в тике):

Группа Команды
чат tg-reply [--followup], tg-notify -k, tg-deliver-file, tg-list, tg-seen
задачи task, tasks, complete, block, promote, requeue, close
уровни ladder, status, escalate, escalations, escalation-ack
решения decide, decide --stats
ядро core status / train / backfill / feedback / reflect / report / rollback / retire [--head]
память memory sync [--all] / stats / search "…" --circle N
доверие trust list, trust set <субъект> <1-4> (только человек)
стор events, verify, compact [--dry-run / --vacuum / --verify-archives]

Деплой. Код — ветка master в git.cil.by/symbiot/simbiot-harness. Перезапуск лестницы — только scripts/safe-restart.sh: он ждёт, пока закончится идущий LLM-воркер, чтобы задача не застряла в running. TG-поллер перезапускается напрямую: Telegram копит апдейты. Сжатие стора — scripts/compact-store.sh.

Тесты. tests/test_*.py — скрипты (./venv/bin/python tests/<имя>.py), каждый со своим временным стором (SIMBIOT_DB). Внешние системы подменены: фейковый /v1/systemone, эмбеддинги «мешок слов», заглушки Telegram. Перед каждым коммитом гоняется полный набор и сравнивается с предыдущим прогоном. Несколько старых тестов (test_tg_commands, test_reply_routing, test_portal_mcp, test_tool_budget) падают и на чистом коде — они зависят от живого состояния.

Известные ограничения и дорожная карта

Главное открытое место — косвенные каналы между кругами и автономность часового уровня в круге 1.

Ограничения:

  • Выжимки нижних уровней считаются внутренними. Пересказ сообщений правления в дайджесте попадает в часовой и дневной тик с кругом 1.
  • Часовой уровень в круге 1 может менять код сам. Рефлексия 17.10 закоммитила правило вопреки «код не меняй».
  • Адресация ошибается на коротких репликах без контекста. «Выбирай любую.», сказанное боту, фильтр пропустит; выход — «Коди, …» или reply.
  • Ученики пока слабые. addressing на свежих данных совпадает с учителем в 71% и уверен в ~10% случаев; ML-версия task_fit не бьёт базу; depth ещё не обучался.
  • Расход токенов часового уровня — в среднем 46k токенов на вызов, до 205 вызовов инструментов на тик. Лимит 300 оставлен по решению владельца.
  • Уровни неделя/месяц/квартал/год пока не работают; куб сознательно сжат до 4 тиков.

Дальше:

  • Рефлексия и другие задачи «только предложить» — в ограниченном круге, внедрение после «да» владельца.
  • Круг приватности для дайджестов — закрыть косвенный канал.
  • Команда в боте для смены кругов (только владелец), чтобы не ходить на сервер.
  • Решение по ученику адресации по цифрам недельного отчёта.
  • Режим on для серой зоны гейта — по статистике shadow.
  • Уровни неделя и месяц на основе D3 и дайджестов дня.