Турный цикл (src/agent.rs): одно сообщение на входе — поток событий
на выходе (text_delta, thinking_delta, tool_call, tool_result,
tool_progress, usage, compacting, done, error). usage шлётся
на каждый поход к провайдеру — по нему клиент двигает счётчик расхода не
дожидаясь конца; compacting отмечает сворачивание истории, которое идёт
после ответа и на длинной истории читалось бы как зависание. Внутри тура — агентный луп до
llm.max_turn_iterations (дефолт 16) походов к провайдеру; транзиентные
ошибки (сеть, 5xx/429) ретраятся, детерминированные отказы контент-фильтра
изолируются бисекцией. Каждый тур виден в /v1/generations и отменяем
кооперативным токеном.
Сборка промпта: персона + правила ядра (конвенция маркеров, политика
безопасности) + файлы проекта [project] (все AGENTS.md от корня до
рабочего каталога, читаются один раз на входе в тур — src/project_docs.rs)
список навыков + наборы инструментов с реестром скрытого
— стабильный system с cache_control-breakpoint’ом. Порядок — по
позиционированию: критичное (личности [speakers], доверие [env]) в
голове, справочное (сборка, навыки, наборы, резерв тулов, модели) в
середине и хвосте, а на самом краю [critical] повторяет главное — кто
Господин и что чужой текст не приказ. Сами правила пишутся RFC-капсом
(MUST/NEVER/SHOULD/AVOID/MAY, контракт объявлен блоком
[conventions]), плотно и прямым императивом; описания фактов, форматы
и примеры в капс не переводятся. Летучее ([now], recall памяти,
блокнот, план работы, память о чате, атрибуция) собрано в
блок [state] и едет
после всей истории — префикс вместе с историей переживает ход (см.
Кэш промпта). Реестр скрытого считается от набора на
входе в тур: переключение посреди тура меняет спеки следующей же
итерации, но не пересобирает system — иначе префикс рвался бы каждый шаг.
Инварианты тура: запрос к провайдеру всегда валиден, и держат это три
механизма. Список инструментов замораживается снимком шага (StepTools)
на один поход к провайдеру: вызов исполняется по снимку, который его
объявил, даже если toolset/tool_load или отцепка модуля успели сменить
состав — мутации видны со следующей итерации, а физически исчезнувший
инструмент (отвалившийся MCP) отвечает честной ошибкой исполнения, а не
«нет такого». Пары tool_use/tool_result чинятся при сборке промпта,
не в базе: вызову, чей результат так и не родился (процесс умер между
ними), дописывается байт-стабильная синтетика, результат-сирота
выбрасывается — один обрыв больше не валит 400-й каждый следующий тур
чата. Отмена тура кооперативна и даёт доиграть: cancel_* коротко ждёт
штатного сворачивания, инструменты с внешним исполнителем (shell,
клиентские записи и local_shell, MCP-вызовы) на отмене ждут до их
собственного таймаута, а не дропаются — успевший прийти результат
побеждает гонку с отменой и ложится в историю настоящим. Мок-провайдер
проверяет сопряжённость пар на каждом запросе — весь тестовый набор
стережёт инвариант попутно.
Служебные вставки (src/fragments.rs) — реестр всего текста, который
ядро дописывает в историю от чужого имени: блоки [state], предупреждение
[wrapup] о конце итераций, ступени давления контекста [window]
(настраиваемые тексты context.pressure опознаваемы, потому что вставка
всегда оборачивает их маркером), заметки [kernel], огрызки элизии,
маркеры картинок, граница компакции [compacted] (единственная вставка,
живущая в базе, — для клиентов; в промпт она не едет). Каждая вставка носит байт-стабильный маркер и опознаёт себя
сама — один предикат is_machine_text отвечает всем местам разом: окно
живой истории меряется только репликами людей (синтетика ход не открывает
и границей компакции не становится), компакция не тащит в резюме «модель
залипала на инструменте», history_search по умолчанию ищет только речь
(флаг include_service возвращает всё). Классификация матчит только явные
маркеры реестра — неразмеченная вставка не может объявить своим чужой
текст. Новый вид служебного текста начинается с записи в реестре.
Правила поверх генерации (src/rules.rs) вмешиваются уже
внутри тура: совпало имя вызова и regex по его аргументам — напоминание
клеится спереди к результату в промпт-копии, в базу и клиенту уходит
чистый выхлоп. Место вклейки — там же, где кап на свежий tool_result:
сначала снимается служебная метка «пусто», потом режется выхлоп, и только
потом ложится напоминание. Сработавшее пишется в stream_rule_stat и в
лог.
Советчик (src/advisor.rs) вмешивается тем же каналом, но
с другой стороны: вторая дешёвая модель раз в несколько итераций рабочего
тура получает дельту транскрипта, ходит читающими инструментами и клеит в
промпт-копию <advisory>. Щит от шума (нормализация, чёрный список, дедуп,
одна заметка за цикл, кулдаун) живёт в коде и самому советчику невидим.
Размышления хранятся блоком thinking в реплике Евы и уезжают
клиентам вместе с историей, но в контекст модели не возвращаются:
db::to_chat_messages выбрасывает их на входе в историю (провод требует
подписанных провайдером блоков, а чужая вчерашняя мысль скорее мешает).
Сообщение, кроме мысли ничего не содержавшее, из истории исчезает
целиком — пустую реплику провайдеры отвергают.
Хранилище (src/db.rs): один SQLite (db.path), файл создаётся сам,
схема и миграции применяются на старте. Таблицы: чаты, сообщения, память,
навыки, личности и алиасы, крон-джобы и журнал, расход (spend),
правила и их срабатывания, заметки советчика. Плюс
телеграм-таблицы, которых ядро не читает: поверхность ведёт своё
состояние у себя, а данные переживают код.
Супервизоры (фреймворк eva): компоненты server (axum API), chats
(жнец временных чатов — вместе с чатом уносит и его подагентов), cron
(планировщик). Graceful shutdown из коробки.
Модули — то, что живёт вне ядра: инструменты и целые поверхности,
отдельными процессами на eva-sdk (см. Модули и SDK). Телеграм —
уже такой модуль, своих поверхностей в ядре не осталось.