Один verbose build — и агентный CLI съел контекст: как gemini-cli учится резать shell-вывод

Соло-разработчик с агентным CLI в терминале рискует потерять половину окна контекста одной «шумной» shell-командой: вывод уходит в tool-result, и модель начинает разбирать лог вместо кода. В разборе фикса для open-source агента gemini-cli показано, почему лимит на stdout — не косметика, а защита агентного цикла, и почему наивная обрезка строки в JavaScript может тихо испортить текст, который модель увидит дальше.
Как shell tool засыпает контекст LLM в агентном CLI
gemini-cli — open-source coding agent с Gemini в терминале. Shell tool входит в tool-контур: stdout и stderr локальной команды попадают в tool-result и дальше — в llmContent, то есть в контекст, который провайдер отдаёт модели.
Пока верхнего лимита нет, один verbose build или длинный лог могут добавить десятки тысяч токенов «мусора», который агент не запрашивал. Опциональная LLM-суммаризация (summarizeToolOutput) может смягчить удар, но без неё поток идёт целиком.
Баг зафиксирован в issue #28090 репозитория google-gemini/gemini-cli («Gemini CLI sends large shell output back to the provider»). Репортёр на версии @google/gemini-cli@0.47.0 наблюдал 40319 байт в payload tool-result — при пороге resource-bound в 8192 байт для этого класса проблем. Standalone-reproducer воспроизводит сценарий с большим stdout через mock provider; статус репро — REPRODUCED.
Для vibe-coding workflow с агентными CLI — Cursor, gemini-cli или любой аналог с shell-tool — тот же класс риска актуален везде, где stdout команды становится частью промпта: агент буквально может засорить себе окно контекста одной строкой в терминале.
Почему «просто slice(0, MAX)» — ловушка, а не фикс
Заголовок исходного материала честно предупреждает: первый драфт патча мог бы дать corrupted text. Наивный one-liner выглядит очевидно:
output.slice(0, MAX) + marker + output.slice(-MAX) // MAX = 32 * 1024
В JavaScript строки — последовательности UTF-16 code units, не байты и не Unicode codepoints. Символы вне Basic Multilingual Plane — emoji в commit message, box-drawing в progress-bar, нелатинские имена файлов — кодируются surrogate pair: два 16-битных code unit, значимых только вместе.
.slice() между ними не бросает исключение. Он оставляет dangling unpaired surrogate с обеих сторон среза — тихую порчу строки, уходящей в модель. JSON.stringify payload может упасть позже при сериализации tool-result, либо модель получит слегка искажённый символ на границе truncation — и это не заметят без byte-diff. На ASCII-only тестах такой баг не всплывает.
Именно этот сценарий автор называет формой дефекта, которую «очевидно правильный» фикс внёс бы, если резать строку по code units, а не по байтам на границе валидного UTF-8.
Фикс по байтам: truncateLlmOutput и границы codepoint
В открытом PR #28401 (fix(shell): bound command output sent to the model) предложена функция truncateLlmOutput() в packages/core/src/tools/shell.ts. Ключевые решения:
- жёсткий лимит
MAX_LLM_OUTPUT_BYTES = 32 KiB(32 × 1024 байт) на объём, уходящий в модель; - стратегия head + tail: сохраняются начало и конец вывода, между ними — маркер с числом отброшенных байт;
- усечение в байтах, границы среза выравниваются по валидным UTF-8 codepoint через
safeSliceToCodepointBoundary; - ограничение применяется и к нормальному завершению команды, и к прерванной (aborted-command path);
returnDisplay— то, что видит пользователь в терминале — не меняется; cap только наllmContent.
Опциональная summarizeToolOutput остаётся; byte-cap — жёсткий backstop, когда суммаризация не настроена.
Ручная проверка из PR: команда
node -e "console.log('x'.repeat(40319))"
через shell tool даёт llmContent не больше 32768 байт (32 KiB) с сохранением head и tail. Автор поста добавил четыре unit-теста с multi-byte контентом на границе truncation; кейс 40319 байт усечён до ровно 32768 байт без dangling surrogates. По его словам, полный shell-tool suite — 93 теста green.
На дату публикации разбора issue #28090 и PR #28401 остаются open; в npm-релизе фикс не подтверждён.
Что перенести в свой агентный workflow
Если вы гоняете агентный CLI в соло-цикле — Cursor, gemini-cli или любой аналог с shell-tool — три практических вывода из этого кейса:
- Разделяйте «что видит человек» и «что ест модель». Ограничение на
llmContentбез урезания терминального вывода — правильный компромисс: разработчик читает полный лог, агент не тонет в нём. - Лимитируйте в байтах, режьте по codepoint. Любая функция slice/truncate для untrusted данных — shell output, содержимое файлов, ответы API — должна учитывать multi-byte и surrogate boundaries, а не только ASCII.
- Тестируйте границу, а не «средний» текст. Чеклист из материала: есть ли в фикстуре хотя бы один символ вне Basic Multilingual Plane ровно на границе truncation? Если фикстура целиком ASCII — тест не покрывает реальный риск.
Один verbose npm run build не должен съедать контекст, ради которого вы вообще запустили агента. Пока фикс не в релизе, имеет смысл держать в голове resource-bound для shell-команд и не полагаться на то, что агент сам «переварит» бесконечный stdout.
Источники
- Пост @enjoy_kumawat на Dev.to — «I Fixed Unbounded Shell Output in an Open Source Agent. My First Draft Would Have Corrupted Text.» — Dev.to (доступ: 2026-07-20 UTC)
- Issue #28090,
google-gemini/gemini-cli— https://github.com/google-gemini/gemini-cli/issues/28090 (доступ: 2026-07-20 UTC) - PR #28401,
fix(shell): bound command output sent to the model— https://github.com/google-gemini/gemini-cli/pull/28401 (доступ: 2026-07-20 UTC) - Reproducer gist
gemini-cli-huge-stdout-resource-bound.reproduce.py— https://gist.github.com/N0zoM1z0/1ed81a6deb8488925e67fcc64a53a67c (доступ: 2026-07-20 UTC)