Проблема, которую это решает
Инструмент, написанный прямо на системных вызовах, знает лишнее: read знает про readFileSync, bash — про execSync, и оба знают, что работают на Node. В момент, когда исполнять надо где-то ещё — в удалённой машине, в виртуальной файловой системе, в контейнере, — переписывать приходится каждый инструмент.
Порядок здесь важнее самого приёма: контракт пишется до реализации. Сначала отвечают на вопрос «что среда обязана уметь, чтобы любой инструмент мог её вызвать», и только потом заводят первую реализацию.
Контракт: чем меньше, тем лучше
Минимальный набор, вокруг которого всё держится:
| Метод или поле | Зачем | Обязателен |
|---|---|---|
readFile(path) |
прочитать файл | да |
exec(command) → { stdout, exitCode } |
выполнить команду | да |
stop() |
завершиться аккуратно | да, пустая реализация допустима |
type |
какая среда — для логов и подсказки | да |
workingDirectory |
база для путей | да |
expiresAt |
момент истечения аренды | нет, только у облачной |
snapshot() |
сохранить состояние | нет, только у облачной |
Три решения в этом контракте стоят того, чтобы их назвать:
Все методы асинхронны, даже когда реализация синхронна. Локальная среда оборачивает синхронные вызовы без нужды, но облачная асинхронна по-настоящему, а разные сигнатуры у реализаций одного контракта — источник постоянной боли на стороне вызывающего.
Необязательные возможности объявляются необязательными, а не заглушками. Локальная среда не истекает, виртуальная не умеет снимков. Пометить expiresAt и snapshot как опциональные честнее, чем заставлять каждую реализацию возвращать заглушку, которую нельзя отличить от настоящего ответа.
Интерфейс делается настолько малым, насколько получится. Всё, что добавлено сейчас, обязана поддерживать каждая реализация — и та, которой это не нужно, и та, которая появится через год. Это то же соображение, что и в Orchestrator Adapter: абстракция дорожает не в момент написания, а в момент второй реализации.
Наглядный случай — добавить writeFile. Сразу возникает вопрос, что делать средам, куда писать не полагается: заводить ещё один необязательный метод, разделять интерфейс на «читающий» и «пишущий» или бросать исключение. У каждого ответа своя цена, и платится она везде сразу.
Три реализации и чем они различаются
| Локальная | В памяти | Облачная | |
|---|---|---|---|
| Стоимость | бесплатно | бесплатно | поминутно |
| Задержка вызова | микросекунды | микросекунды | десятки-сотни миллисекунд |
| Изоляция | нет | частичная: читает настоящее, пишет в память | полная, отдельная машина |
| Сохранность | постоянная | исчезает при остановке | снимок и восстановление |
| Ограничение по времени | нет | нет | жёсткое, обычно 30–60 минут |
Реализация в памяти — не упрощение, а отдельный инструмент. Виртуальная файловая система с семантикой копирования при записи: агент читает настоящие файлы, но всё, что он пишет, живёт в памяти и исчезает при остановке. Это даёт то, чего не дают остальные две, — возможность запускать агента на своём коде, не доверяя ему этот код. Отсюда её главные применения: исследование чужого репозитория, тесты харнесса и разбор недоверенного кода.
Ловушка у неё ровно одна и повторяемая: точка монтирования. Каталог, отданный виртуальной среде, монтируется не в корень и не по своему исходному пути, а по фиксированному внутреннему адресу, и каждый путь внутри приходится переводить. Забывается это всеми и всегда.
Проверка, что рефакторинг был структурным
Свойство, ради которого приём и делается, проверяется одним наблюдением: после перевода инструментов на интерфейс поведение агента на тех же запросах не меняется. Те же инструменты, те же результаты, другая проводка под ними.
Если поведение изменилось — маршрутизация, вывод, новая ошибка, — значит рефакторинг протёк: где-то инструмент по-прежнему тянется к системному вызову мимо контракта. Это дешёвый и надёжный тест, потому что он не требует ни стенда, ни разметки: достаточно прогнать те же запросы, что и до правки.
Выигрыш при этом появляется не здесь, а на второй реализации — когда её подключают, не тронув ни одного инструмента.
Хуки жизненного цикла
Создать среду — половина работы. Свежая удалённая машина не знает вашего пользователя git, в ней нет зависимостей и переменных окружения; а перед выключением надо решить, что делать с несохранённой работой. Для этого — три необязательных хука:
| Хук | Когда | Типичное содержимое |
|---|---|---|
afterStart |
среда создана и принимает команды | настройка git, установка зависимостей, копирование окружения |
beforeStop |
перед выключением | проверить незакоммиченное, сохранить, сделать снимок |
onTimeout |
среда упёрлась в лимит времени | обычно то же, что beforeStop, плюс запись в журнал |
onTimeout вызывается харнессом, а не вами — этим он отличается от первых двух. Локальной среде хуки почти не нужны; облачная без них неупотребима. Подробнее про то, что происходит дальше, — Sandbox Lifecycle.
Граница с соседними заметками
Три заметки про песочницу отвечают на три разных вопроса, и путать их дорого:
- Agent Sandboxing — чем изолировать: контейнер, микровиртуальная машина, gVisor, WASM. Про надёжность периметра.
- Эта заметка — какой контракт между инструментами и средой. Про подменяемость.
- Sandbox Lifecycle — как эксплуатировать арендованную среду: состояния, стоимость простоя, снимки. Про деньги и потерю работы.
Изоляция и подменяемость независимы: локальная реализация не изолирует вовсе, но контракту удовлетворяет; облачная изолирует полностью и удовлетворяет тому же контракту.
Связано с
- Orchestrator Adapter — тот же приём на другой оси: там граница с фреймворком оркестрации, здесь со средой исполнения
- Agent Sandboxing — чем изолировать, в отличие от того, какой контракт
- Sandbox Lifecycle — что происходит со средой дальше: состояния, стоимость, снимки
- Tool Calling — инструменты, которые этот контракт вызывают
- Agent Harness — обвязка, для которой среда исполнения одна из точек расширения
- Bounded Tool Output — второй контракт того же слоя: сколько инструменту позволено вернуть