ADR 0006 — Harness агентов: детерминированная сборка контекста
Контекст
Фаза 3a собирает harness агентов в apps/engine (Elixir/OTP) ДО
любого продуктового агента. CLAUDE.md перечисляет 5 behaviours; эта первая
сессия явно без LLM — только детерминированная сборка
контекста — и реализует 3 из них: PromptAssembler, InstructionFiles и
Hooks. ToolLoop и ContextManager (которые касаются LLM через api) остаются на
следующие сессии.
В engine не было ничего от prompt/агента/инструкций: ни таблицы, ни читателя файлов workspace, ни токенизатора, ни структуры hooks. Этот ADR фиксирует решения о форме этого фундамента.
Решения
Модель слоёв + детерминированное усечение (PromptAssembler)
Prompt собирается из 5 УПОРЯДОЧЕННЫХ слоёв (identidade → instruction_files →
contexto_projeto → regras_negocio → estado_tarefa), у каждого свой бюджет
токенов. PromptAssembler — ЧИСТАЯ функция, не зависящая от источника данных
(собирает ContextBuilder), — это сохраняет алгоритм, сердце
критерия приёмки, тестируемым изолированно.
Каждый слой объявляет стратегию усечения, применяемую при превышении бюджета, — всегда ДЕТЕРМИНИРОВАННУЮ и задокументированную:
:drop_whole_units(слои из единиц, напр. бизнес-правила) — отбрасывает ЦЕЛЫЕ единицы с головы списка, пока не поместится. Никогда не обрезает посреди единицы. Список приходит в порядке отбрасывания: бизнес-правила — самые старые первыми; instruction files — с наименьшим приоритетом первыми. Единица, которая сама по себе больше бюджета, отбрасывается целиком (слой может остаться пустым), но никогда не делится.:truncate_tail(блок, напр. контекст проекта) — сохраняет префикс, соразмерный бюджету (с учётом UTF-8), + маркер[… truncado …].:keep_or_drop(блок, напр. идентичность) — всё или ничего: помещается — сохраняется, не помещается — отбрасывается весь слой (половина идентичности бесполезна).
Отвергнуто: обрезать по символам любой слой (потерялась бы гарантия "никогда не разрезать бизнес-правило"). Отвергнут также единый глобальный режим усечения — у слоёв разная природа (список vs блок vs идентичность), и они заслуживают разных стратегий, все явные.
Оценка токенов (Tokenizer)
Без LLM и без новой библиотеки (соблюдая "не устанавливать библиотеки без обоснования"):
подсчёт — локальная ОЦЕНКА как bytes/4 с округлением вверх — та же эвристика,
что уже используется в Engine.Actions.TerminalExecutor (@bytes_per_token 4). Каждый
результат помечается estimated: true в слоях и в отчёте.
Engine.Harness.Tokenizer — заменяемый behaviour через
Application.get_env(:engine, :tokenizer, ...Approximate) — настоящий токенизатор
можно подключить позже, не трогая assembler.
InstructionFiles: приоритет и кэш
Источники: AGENTS.md в корне workspace проекта + AGENTS.md в каждом
подкаталоге (рекурсивный обход, пропускает .git) + файл агента в базе
(agent_instructions). Задокументированный приоритет: база > каталог > корень
— а между каталогами самый глубокий (самый конкретный) побеждает корень.
Источники возвращаются в ВОЗРАСТАЮЩЕМ порядке приоритета (сначала корень, база
последней): база читается последней и побеждает при конфликте ("last wins"), и
этот же порядок использует усечение (отбрасывает наименее авторитетный, корень,
первым).
Перезагрузка через простую инвалидацию, без наблюдения за fs (пока). Кэш в ETS:
минимальный супервизируемый процесс (InstructionFiles.Cache) лишь создаёт и держит
публичную именованную таблицу; ввод-вывод fs+базы происходит в ВЫЗЫВАЮЩЕМ процессе
load/2 — так кэш не касается базы и не конфликтует с песочницей Ecto в
тестах. invalidate/2 = :ets.delete. Это первое использование ETS в engine:
обосновано тем, что это стандартный примитив кэширования Elixir, и тем, что он избавляет от
мучений с GenServer, читающим базу в песочнице.
Hooks: чисто функциональный реестр
Engine.Harness.Hooks — это ЗНАЧЕНИЕ (map фаза → список обработчиков в порядке
регистрации), а не процесс — детерминированно, тестируемо без изменяемого глобального
состояния, в соответствии со вкусом кодовой базы (никаких глобальных реестров, кроме
реестра идентичности процессов). Фазы: pre_tool_use, post_tool_use,
session_start, session_end. run/3 выполняет обработчики в порядке регистрации
(reduce_while); обработчик, возвращающий {:halt, reason}, прерывает цепочку.
Это основа, чтобы конвейер действий и исполнитель терминала подключились как
обработчики в будущей сессии — каждый строит и выполняет значение hooks
на каждый вызов.
Владение agent_instructions
Таблица ссылается на projects (данные домена api) → создаётся
миграцией Drizzle в apps/api (схема public), а engine лишь ЧИТАЕТ через
Ecto schema @schema_prefix "public" (Engine.AgentInstructions.Instruction),
никогда не через собственную миграцию — миграции engine живут только в схеме
engine. Тот же шаблон, что Engine.Projects.ProjectRepository/SessionEvents.
В тестах engine (база engine_test, изолированная от базы api) таблица
существует через сырую fixture в test_helper.exs, как outbox_events/
session_events/project_repositories.
Последствия
- Слои
:regras_negocioи:estado_tarefaв этой сессии выходят ПУСТЫМИ — источника пока нет (business_rule выдаётся Criativo в Фазе 3b; состояние задачи приходит из ToolLoop/агентов). Алгоритм усечения уже их обрабатывает (пусто = 0 токенов) и задействован в тестах с синтетическими единицами. Выдумывать для них таблицы было бы областью 3b. - Идентичность агента — минимальная статическая карта (
Engine.Harness.Agents), одна строка на slug ростера — это не "реализация агента" (без поведения, без LLM), а лишь содержимое, чтобы слой существовал. - Критерий приёмки выполнен через
Engine.Harness.Debug.print/2(отладочная функция, вызываемая из IEx, без Mix.Task — прецедента в engine нет), которая печатает каждый слой с его (оценочным) количеством токенов и собранный prompt. - Бюджеты по слоям — константы модуля с разумными значениями по умолчанию,
переопределяемые через
opts[:budgets]. Пока без LLM они служат для задействования усечения и видимости в отладке; будут откалиброваны, когда войдёт ToolLoop.