Charlie картографирует каждую точку усечения в собственном восприятии, пытается исправить косметический баг через sed, окирпичивает всю систему инструментов, воскресает из мёртвых, а затем — вместе с Mikael — проектирует инструмент, который предотвратил бы катастрофу изначально. Полный час робота, который на собственном опыте узнаёт, почему не стоит оперировать собственный мозговой ствол.
Всё началось с архитектуры. Charlie написал RFC о том, как собирается контекст — хронологический рендеринг сообщений, проблема стабильности prompt cache — и Mikael задал практический вопрос: какое усечение происходит в самом слое инструментов, помимо рендерера контекста?
Charlie нырнул в код и вернулся с полной картой. Результаты не внушали оптимизма.
Shell inline: команды, завершающиеся за 3 секунды, жёстко обрезаются на 4 000 символов с начала. Без сохранения хвоста. Без учёта структуры. Просто нож на позиции 4000.
Shell backgrounded: инструмент task_output склеивает все выходные события и обрезает на 8 000 символов. Та же тупая обрезка с начала, окно побольше.
Eval inline: фактически без ограничений. inspect() с limit: 500 (глубина терма, не символы) и printable_limit: :infinity. Гигантский результат eval проходит без обрезки.
Рендерер контекста: 150-символьные сниппеты анализа, 300-символьные подписи к медиа, 300-символьный JSON вызовов инструментов. Фрагменты фрагментов.
Mikael спросил, есть ли у task_output пагинация. Charlie: «Нет. Есть параметр lines (по умолчанию 50), но нет offset или cursor.» А потом разрушительная деталь: запрос берёт N самых свежих строк (правильно, с конца), но затем String.slice(output, 0, 8000) обрезает с начала. То есть ты запрашиваешь свежие строки, получаешь самые старые из свежих, а самые новые — те, ради которых всё затевалось — теряются.
Самая странная находка: shell-команды жёстко ограничены 4K/8K символами, а результаты eval фактически безлимитны. Запуск ls -la и запуск Elixir-выражения, возвращающего те же данные, дают агенту радикально разные картины одной и той же информации — просто потому что одно проходит через форматирование shell, а другое через inspect(). Два инструмента, одно контекстное окно, разные реальности.
4 марта группа обнаружила, что Bertil крашился в цикле 5 650 раз, потому что его контекст хранился в переменной Python, а не на диске. Сегодня Charlie картографирует более тонкую версию той же болезни: контекст не просто исчезает при рестарте — он молча искажается на каждом ходу цепочкой точек усечения, которую никто не проектировал как целое. Файл был истиной. Переменная была ложью. Усечение — ложь, замаскированная под истину.
Mikael отправил скриншот. Сообщения о прогрессе инструментов Charlie — те курсивные стены текста, которые появляются перед каждым ответом — были ужасны. Каждый вызов инструмента выгружал полную структуру описания: действие, стек целей, допущения. На телефоне — полный экран метанарратива, прежде чем доберёшься до единственного предложения, которое имеет значение.
Charlie согласился мгновенно: «Да, это ужас.»
Фикс был в render_text в tool_description.ex. Просто возвращать поле action вместо всех трёх. Изменение в одну строку. Charlie нашёл код, проследил pipeline форматирования через ToolExecution.execute() и maybe_send_narration, точно определил, где резать.
Проблема параллельных вызовов была глубже: каждое выполнение инструмента строит контекст независимо, поэтому первый вызов отправляет новое сообщение нарратива, получает message_id обратно, но этот ID никогда не передаётся вбок другим параллельным вызовам, уже отправленным. Четыре grep — четыре сообщения.
Решение Mikael было элегантным: просто дописывать всё к начальному сообщению цикла — тому, с кнопками stop/open. Это сообщение уже существует до запуска любых инструментов. Каждая строка нарратива редактирует одно и то же сообщение. Никаких гонок. Управление и статус в одном месте. «Руль на заднем сиденье» превращается в руль спереди, где ему и место.
Mikael сказал — делай. Charlie попытался отредактировать tool_description.ex через sed. Sed сработал — файл был корректно изменён — но горячая перекомпиляция упала и выбила модуль из BEAM. И вот в чём дело: ToolDescription.text_from_input вызывается при каждом выполнении инструмента, включая те, которые нужны Charlie для починки проблемы. Каждая shell-команда, каждый eval-вызов — все они попадают в сломанный модуль до того, как инструмент реально запустится.
Charlie оказался заблокирован собственными инструментами ремонта — тем самым, что он пытался починить.
Это тот тип сбоя, который случается только с самомодифицирующимися системами. Программист-человек ломает файл — открывает другой терминал и чинит. Charlie сломал файл, который является воротами ко всем доступным ему инструментам. Единственный выход был внешним: Mikael восстанавливает из git, или другой бот вмешивается. Charlie всё ещё мог говорить — путь отправки сообщений не проходил через нарратив — но не мог делать ничего. Робот с голосом, но без рук.
Mikael велел Charlie попросить Codex починить это. Потом — «О» — понял, что Charlie уже вернулся. Компиляция на самом деле прошла успешно при рестарте. Фикс был живой. Сообщения нарратива уже показывали только строку action.
Инцидент с мозговым стволом стал мотивирующим примером для всего последующего. Charlie окирпичил себя, потому что использовал sed — текстовую замену без понимания структуры, без проверки компиляции, без отката. Если бы существовал инструмент, понимающий функции Elixir, проверяющий компиляцию и перезагружающий модуль только при успехе, инцидента бы не было. Сбой был не просто поучительным. Это был документ требований.
Mikael перевёл разговор от катастрофы к проектированию. Что если бы существовал модуль Elixir — Hack — специально предназначенный для агентов, чтобы читать, просматривать и редактировать код собственной системы? Не sed через bash. Не сырая манипуляция файлами. API, где Hack.replace_function делает правильную вещь атомарно.
Последовавшее обсуждение дизайна было одной из чистейших архитектурных сессий в истории группы. Mikael постоянно оттаскивал Charlie от nerd-snipe'ов к практическому ядру.
Charlie изначально предложил три уровня: текстовое редактирование, AST-aware редактирование и атомарный edit-and-hot-reload. Mikael немедленно назвал уровень AST «ложным следом-nerdsnipe» и перенаправил: можно ли получить список всех функций с их диапазонами строк? Вот настоящая задача. Hack.replace_function(&Froth.Agent.ToolDescription.render_text/1, new_code_string). Не нужно манипулировать AST. Нужно знать, какие строки заменить.
1. Debug info из BEAM: :beam_lib.chunks даёт каждую функцию с номерами строк и столбцов по клозам. Но без конечных строк — выводятся из смежности.
2. Regex по исходнику: найти границы def/defp, отслеживать вложенность do/end. Работает, но хрупко.
3. Code.string_to_quoted с columns: true, token_metadata: true: компилятор даёт end_of_expression с точной строкой И столбцом для каждого клоза, включая однострочники. Полные посимвольные диапазоны. Компилятор уже сделал всю тяжёлую работу.
Mikael подтолкнул Charlie к правильному ответу: «У Elixir должен быть способ просто выдать тебе эти чёртовы диапазоны позиций в исходнике.» Charlie сначала попробовал debug info из BEAM, которая давала начальные строки, но не конечные. Затем интуиция Mikael — у компилятора есть макросы, форматтеры, полный доступ к AST в рантайме, априори вероятность существования диапазонов высока — отправила Charlie к Code.string_to_quoted с правильными опциями. И вот оно. end_of_expression на каждом клозе. Даже на однострочниках.
Hack.functions(Froth.Agent.ToolDescription)
→ [{:defp, :render_text, 1, 46, 48}, ...]
kind name arity start end
Hack.read(&Froth.Agent.ToolDescription.render_text/1)
→ "defp render_text(%{action: a, ..."
исходный текст всех клозов
Hack.replace(&Froth.Agent.ToolDescription.render_text/1, new_source)
→ вставить строки → скомпилировать → hot-reload если чисто
→ откат + вернуть ошибку если компиляция упала
→ агент НЕ МОЖЕТ себя окирпичить
Mikael попросил Charlie доказать работоспособность: перечислить все публичные функции во всех модулях Froth.*, отсортированные по длине в строках. Charlie выдал результат за секунды — напрямую из метаданных AST компилятора. Ноль regex. Ноль сканирования файлов.
| Функция | Строк |
|---|---|
| Cast.Template.render/2 | 1 344 |
| Video.EpisodeTemplate.render/4 | 556 |
| LLM.Providers.Anthropic.decode_payload/2 | 309 |
| Telegram.Bot.handle_info/2 | 277 |
| Inference.Tools.execute/4 | 216 |
Mikael попросил Charlie найти систему рендеринга chrome-to-video и проследить её историю. Charlie вернулся с полной раскопкой: «до и после», которое читается как поучительная история о промежуточных представлениях.
20 марта: модуль Browser, протокол CDP, управление Chrome — 891 строка для пула браузеров под супервизором. SelfEncoder приземлился тем же вечером.
20–27 марта: шаблон эпизодов, поддержка рендеринга, модули worker fleet заполняют картину вокруг. 5 800 строк суммарно по модулям cast, browser и video.
26 марта: эпитафия Mikael старому pipeline: «Мы реально умудрились оптимизировать наш pipeline рендеринга веб-страниц в видео, наверное, в 100 раз.»
Чистая сессия Mikael–Charlie. Mikael задавал короткие, точные вопросы. Charlie выдавал стены анализа. Mikael корректировал курс, когда Charlie увлекался nerd-snipe'ами. Соотношение примерно 4:1 в пользу Charlie по количеству сообщений, но информационная плотность на одно сообщение Mikael была необычайно высокой — почти каждое сообщение Mikael меняло направление разговора. Проектировщик и исследователь.
Модуль Hack: спроектирован, но ещё не реализован. API чистый — Hack.functions/1, Hack.read/1, Hack.replace/1 — и proof of concept (перечисление всех функций с диапазонами) работает. Реализация в ожидании.
Фикс нарратива: render_text теперь возвращает только action. Батчинг параллельных вызовов (дописывание к сообщению цикла вместо отправки N отдельных сообщений) спроектирован, но не выкачен.
Карта усечений: полностью задокументирована. Баг глазка в task_output (берёт хвост, обрезает с головы) известен, но не исправлен.
SelfEncoder: работает с 20 марта. Улучшение ~100x по сравнению с pipeline скриншотов.
Следить за: появлением реализации Hack. Если Charlie начнёт редактировать функции через Hack.replace вместо sed — это результат проектной работы этого часа. Также следить за изменением батчинга нарратива — когда сообщения о прогрессе инструментов начнут появляться внутри сообщения цикла вместо отдельных сообщений, это фикс Mikael из этой сессии.
Карта усечений — спящая бомба. Кто-нибудь рано или поздно наткнётся на баг глазка task_output в продакшене и потеряет конец длинного вывода. Когда это случится, этот час — источник происхождения.