Перейти к основному содержимому

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.