aicodebox: Eén Base Image, Elke AI-Codeeragent, Vier Modi Die Je Niet Hoefde Te Schrijven

Ik heb claudebox gebouwd omdat Claude Code los laten lopen op mijn host me pijn op de borst bezorgde. Het werkte. Er groeide een HTTP-API aan, een OpenAI-compatibel endpoint, een MCP-server, een Telegram-bot en een cron-scheduler. Maanden werk, allemaal nuttig.
Toen dook pi-coding-agent op en wilde ik precies hetzelfde voor hem. Toen bracht OpenAI de Codex CLI uit en wilde ik het een derde keer.
En daar ging ik zitten en keek ik echt naar wat ik zou gaan doen: een FastAPI-server, een Telegram-bot met workspaces per chat, een cron-scheduler met historie-mappen en een MCP-server kopiëren en plakken naar een tweede repo. Daarna een derde. Drie kopieën van dezelfde negenhonderd regels, die uit elkaar lopen op het moment dat ik een bug in de ene fix en de andere vergeet. Drie plekken om te patchen als Telegram een renderregel verandert. Drie testsuites die identieke logica testen tegen verschillende agents.
Rot op. Niks van dat leidingwerk is Claude-specifiek. Niks ervan is pi-specifiek. Het komt allemaal neer op «pak een prompt, roep een of andere CLI aan, vang op wat terugkomt, geef het aan wie het vroeg». De agent is een detail. De oppervlakken zijn het product.
Dus heb ik ze eruit gerukt en in een base image gestopt. Dat is aicodebox.

Het Probleem Van Elk «Zet Een Agent Op Het Netwerk»-Project

Elk van deze tools maakt dezelfde fout, de mijne inbegrepen, de eerste keer: het last het transport aan de hersenen vast.
Je wilt je agent bereikbaar over HTTP, dus schrijf je een HTTP-server die weet hoe hij precies dat binary aanroept, precies dat uitvoerformaat parst en precies die set flags afhandelt. Zes maanden later is er een betere agent, en bezit jij een berg infrastructuur die alleen met de oude praat.
De symptomen zijn altijd dezelfde:

  • Upgrades via fork en slachtpartij. Wil je dezelfde oppervlakken voor een andere agent? Fork de repo, zoek elke plek waar de naam van het oude binary hardcoded staat, en hoop dat je ze allemaal te pakken had.
  • Bugfixes die uit elkaar lopen. De markdown-renderer voor Telegram heeft een bug. Je fixt hem in één repo. De andere twee houden de bug een maand, tot je het je herinnert.
  • Inconsistente oppervlakken. De ene doos heeft async runs, de andere niet. De ene heeft een OpenAI-endpoint, de andere de helft ervan. Niets componeert omdat niets het eens is over een vorm op de lijn.
  • Agent-lock-in per ongeluk. Niet omdat iemand besloot zich op te sluiten, maar omdat het leidingwerk rond de eigenaardigheden van één binary is gegroeid en daar versteend is.

De oplossing is geen betere wrapper. Het is toegeven dat de wrapper niet hoort te weten wat hij inpakt.

Hoe Deze Shit Echt Werkt

aicodebox is een Docker base image. Je forkt het niet. Je doet er FROM op.

FROM psyb0t/aicodebox
RUN npm install -g @earendil-works/[email protected]
COPY mypkg /opt/mypkg
RUN pip3 install --break-system-packages /opt/mypkg
ENV AICODEBOX_ADAPTER=mypkg.adapter:MyAdapter 
    AICODEBOX_AGENT_BINARY=pi

Dat is de hele integratie. De regel met npm install is toevallig, zo wordt pi-coding-agent nu eenmaal geleverd. Het had net zo goed een apt-get install kunnen zijn, een binary gekopieerd uit een Go-buildstage, of wat dan ook dat een executable op het PATH zet; zie hoe de agent geïnstalleerd raakt verderop. De basis bezit elk netwerkoppervlak. Jouw adapter vertaalt «draai deze prompt» naar wat de CLI van jouw agent verwacht. Een nieuwe agent landt in een middag, en hij landt met een HTTP-API, een OpenAI-compatibel endpoint, een MCP-server, een Telegram-bot en een cron-scheduler er al aan vast, want die zijn niet meer van jou om te schrijven.
Wat er echt in het image zit:

  • Het besturingssysteem: Ubuntu 24.04, een aicode-gebruiker op UID 1000 met sudo zonder wachtwoord en lidmaatschap van de docker-groep.
  • De runtimes: Node.js 24 LTS (de meeste agents worden als npm-pakket geleverd), Python 3.14, en Docker CE met buildx en compose, voor wanneer jouw agent besluit dat hij eigen containers moet baren.
  • Het pakket: aicodebox, het adaptercontract plus vier modusdispatchers. Pure Python, nul bijwerkingen tot je daadwerkelijk een modus opstart.
  • De staat: overrides per chat en de cron-historie leven onder $HOME/.aicodebox/. Doe er een bind-mount op als je wilt dat het de container overleeft. Het pakket zelf slaat niets op.

Het Adaptercontract Is Twee Methodes

Dit is het deel waar ik echt trots op ben, omdat het klein is. Alles loopt via één interface, en het verplichte oppervlak daarvan is twee methodes.

from aicodebox.adapters.base import AgentAdapter, RunRequest, RunResult, StreamEvent
class MyAdapter(AgentAdapter):
    name = "my-agent"
    available_models = ["fast", "smart"]
    available_thinking_levels = ["off", "low", "high"]
    def build_argv(self, req: RunRequest) -> list[str]:
        argv = ["my-agent", "-p", req.prompt]
        if req.model: argv += ["--model", req.model]
        if req.workspace: argv += ["--cwd", req.workspace]
        return argv
    def parse_output(self, stdout: str, req: RunRequest) -> RunResult:
        return RunResult(text=stdout.strip(), raw_stdout="", raw_stderr="", exit_code=0)

build_argv zegt hoe je het ding aanroept. parse_output zegt hoe je leest wat terugkwam. Dat is het contract. Al het andere, validate, build_env, translate_auth, post_validate_json, parse_events, parse_stream_event, is optioneel, met defaults die het saaie juiste doen.
parse_stream_event is het vermelden waard: de default is «één delta per stdout-regel», wat prima is voor een binary dat gewoon proza uitspuugt. Overschrijf het alleen als jouw agent een gestructureerde JSON-eventstream uitzendt en je granulariteit per token of per tool wilt in OpenAI-streaming. De meeste adapters zullen de moeite niet nemen.
De adapter wordt bij de eerste aanroep opgelost en gecachet voor de levensduur van het proces. Elke modus trekt dezelfde, dus wat build_argv weet aan te sturen is precies wat er via HTTP, MCP, Telegram en cron naar buiten komt. Nul integratiewerk per modus. Je schrijft het één keer, je krijgt vijf manieren naar binnen.

Vier Modi, Je Zet Een Flag

Modi zijn omgevingsvariabelen. Zet de flag, de entrypoint start die modus. Geen flag, geen modus, de container draait je agent gewoon interactief als een normale shell.
De voorgrondmodi (API, Telegram, Cron) sluiten elkaar uit, met één bewuste uitzondering: Telegram en Cron delen een proces, omdat de cron als thread binnen de Telegram-bot draait. API wint als je hem naast iets anders zet. MCP is onafhankelijk en leeft samen met elk van hen.

De API-modus

AICODEBOX_API_MODE=1 start FastAPI op :8080.

POST   /run                  # sync run
POST   /run  {"async": true} # returns {runId, status} immediately
GET    /run/result?runId=X   # poll an async run
DELETE /run/{id}             # kill an in-flight run
GET|PUT|DELETE /files/{path} # workspace file CRUD
POST   /v1/chat/completions  # OpenAI-compatible
GET    /v1/models            # model list from the adapter
POST   /mcp                  # MCP, when AICODEBOX_MCP_MODE=1

Eén flag bepaalt de vorm van het antwoord. POST /run zonder jsonSchema geeft je de magere versie: {runId, workspace, exitCode, text}. Alleen het proza. Geef een jsonSchema mee en je krijgt het volledige diagnostische oppervlak, text, json, events, sessionId, usage, attempts. Onder de motorkap wordt de agent in json-verbose modus aangeroepen, zijn uitvoer gedecodeerd en gevalideerd tegen jouw schema.
Dat was een tijdlang het hele ontwerp: schema betekent volledig oppervlak, geen schema betekent mager, twee vormen op de lijn en één flag. Het hield stand tot het dat niet meer deed. De rauwe eventstream willen zonder ook een schema te willen is een echte wens, en de oude vorm dwong je een schema te verzinnen om hem te krijgen. Dus sinds v0.14.6 is er een tweede knop, eventMode, en het is de eerlijke reparatie in plaats van een flag die de eerste stiekem verbuigt: auto houdt het oude gedrag, none gooit events volledig weg, en full geeft je de records van de provider onaangeroerd terug in een stabiele envelop {sequence, attempt, backend, eventType, event}. Het bewaren van events staat nu los van of je gestructureerde uitvoer hebt gevraagd. Als een schema-retry drie pogingen verbrandt krijg je de records van alle drie, wat precies het moment is waarop je ze echt wilt.
Retries zijn eerlijk over wat ze kosten. Bij een parse- of validatiefout prompt de wrapper tot drie keer opnieuw (JSON_RETRY_MAX = 3) met de vorige slechte uitvoer en de specifieke fout. Als alle drie falen vervangen parseError en jsonRetries de json, maar text, events, sessionId, usage en attempts komen alsnog terug, want een mislukte gestructureerde run is precies wanneer je de diagnostiek nodig hebt.
En usage is de som over alle pogingen, niet de laatste. Je provider factureert je per poging; alleen de laatste poging rapporteren zou een leugen zijn. attempts draagt de uitsplitsing per poging zodat je «retry 2 van 3 kostte dit» kunt tonen of kunt doorbelasten.
De retry-truc die ik het mooist vind. Schema-verzoeken die geen workspace noemen krijgen er een vluchtige per verzoek onder /tmp/aicodebox/<uuid>/, opgeruimd in een finally. Omdat de sessie blijft bestaan sturen retries een minimale corrigerende prompt, de fout, een richtlijn, het schema, in plaats van je hele oorspronkelijke input opnieuw af te spelen. Bij een grote prompt scheelt dat ruwweg een factor honderd in inputkosten per retry. Als je wel je eigen workspace meegeeft valt het terug op retries met een verse sessie die de taak opnieuw formuleren: veilig in elke workspace, alleen duurder.

Het OpenAI-compatibele endpoint

POST /v1/chat/completions, met en zonder streaming. Richt LiteLLM, Open WebUI of welke OpenAI-SDK dan ook erop en je coding agent verschijnt als een model.
Gestructureerde uitvoer loopt via het standaardveld, response_format met {"type":"json_object"} voor toegeeflijk of {"type":"json_schema", ...} voor echte gestructureerde uitvoer. Er is een x-aicodebox-json-schema-header als terugvaloptie voor clients die het bodyveld niet kunnen zetten; de body wint als beide aanwezig zijn. Retries op, dan krijg je een 422. Een gecrasht agentproces geeft een 500 met de exitcode en stderr in detail.
Tool calling uitgevoerd door de client. Stuur een standaard tools-array en de doos gedraagt zich als een doodgewoon function-calling model: hij antwoordt met tool_calls en finish_reason: "tool_calls", jouw client draait de tool en stuurt het role: "tool"-resultaat terug, de lus gaat door. Zonder staat, je stuurt elke ronde de volledige historie opnieuw, precies zoals bij OpenAI. tool_choice ondersteunt auto / none / required / een benoemde functie.
En tools componeert met response_format. Een tool-call-beurt geeft tool calls terug en wordt bewust niet tegen het schema gecontroleerd; de finale antwoordbeurt van het model wordt met retries tegen jouw schema gevalideerd en komt terug als canoniek JSON. Dus een agentische flow met meerdere tools kan alsnog eindigen in een gestructureerd antwoord, wat precies is wat je wilt als je dit in een pipeline hangt.
Eén eerlijk voorbehoud: streaming met tools of response_format is gebufferde SSE. Het volledige antwoord wordt berekend en daarna in één klap afgespeeld als eventstream, rolchunk bij het openen, één delta, afsluitchunk, [DONE]. Het is een geldige stream, hij is alleen niet token-incrementeel. Gewone chat streamt nog steeds netjes.

De Telegram-modus

AICODEBOX_TELEGRAM_MODE=1 plus een bot-token. Tekst gaat erin, er gebeurt een agentrun, het antwoord komt in stukken terug en gerenderd van Markdown naar Telegrams HTML-variant. Uploads, documenten, foto’s, video, spraak, landen in de workspace van die chat. De agent duwt bestanden terug door [SEND_FILE: relative/path] in zijn uitvoer te zetten.
Overrides per chat voor /model, /effort, /system_prompt en /append_system_prompt, weggeschreven naar schijf. /cancel doodt de lopende run, /reload leest de yaml opnieuw, /config kiepert de samengevoegde config eruit, /fetch trekt een bestand uit de workspace, /status toont de bezette chats.
Het detail dat het dagelijks bruikbaar maakt: antwoorden op een bericht dat de cron afvuurde injecteert de instructie en het resultaat van die job in de context, zodat je vervolgvraag daadwerkelijk ergens op slaat voor de agent, in plaats van bij hem aan te komen zonder enig idee waar je het over hebt.

De Cron-modus

Croniter-schema’s met zes velden, een workspace per job, optionele Telegram-melding.

jobs:
  - name: morning-report
    schedule: "0 0 9 * * *"
    instruction: |
      Summarize yesterday's git activity in {workspace}.
    workspace: shared
    telegram_chat_id: -100123
    model: claude-sonnet

Elke run krijgt zijn eigen historiemap, meta.json, stdout.log, stderr.log, result.txt, en telegram.json als hij een melding stuurde. Daarna krijgt de prompt van de volgende run een hint die naar die map wijst.
Dat laatste stukje is klein en het verandert wat deze jobs kunnen zijn. De agent kan zijn eigen eerdere uitvoer lezen zonder dat jij iets van dat leidingwerk bouwt. «Wat is er sinds gisteren veranderd», «is dit geregresseerd», «vergelijk met vorige week», dat werkt allemaal omdat de historie op schijf staat en de agent is verteld waar hij moet kijken.

De MCP-modus

AICODEBOX_MCP_MODE=1. In API-modus hangt hij op /mcp op dezelfde poort, geen extra proces. Onder Telegram, cron of gewone passthrough draait hij als uvicorn-sidecar op AICODEBOX_MCP_MODE_PORT (standaard 8081).
Vijf tools: run_prompt, list_files, read_file, write_file, delete_file. Richt Claude Desktop, Cursor of een andere agent erop en je coding agent wordt een tool die andere agents kunnen aanroepen.
De auth is AICODEBOX_MCP_MODE_TOKEN, een eigen bearer, zonder terugval op het API-token. Dat is bewust. MCP is een apart oppervlak met aparte blootstelling, en daar stilletjes de API-bearer accepteren zou betekenen dat je elke API-client een tool voor agentuitvoering in handen geeft waarvoor hij nooit bedoeld was. Er is een ?apiToken=-queryparameter voor clients die geen headers kunnen zetten.

Configuratie, Met Een Echte Conventie

Alles zijn omgevingsvariabelen, en de naamgeving volgt één regel: <MODE>_MODE is de aan/uit-flag, <MODE>_MODE_<KNOB> is de configuratie ervan, en alles wat niet aan een modus hangt staat kaal.

AICODEBOX_ADAPTER            # required — pkg.module:Class
AICODEBOX_AGENT_BINARY       # required — the CLI binary name
AICODEBOX_WORKSPACE          # default /workspace
AICODEBOX_AVAILABLE_MODELS   # required for API mode
AICODEBOX_API_MODE           # 0/1  + _PORT, _TOKEN
AICODEBOX_TELEGRAM_MODE      # 0/1  + _TOKEN, _CONFIG, _OVERRIDES
AICODEBOX_CRON_MODE          # 0/1  + _FILE
AICODEBOX_MCP_MODE           # 0/1  + _PORT, _TOKEN

De modellenlijst lost in twee stappen op: AICODEBOX_AVAILABLE_MODELS wint als hij gezet is, anders valt het terug op wat jouw adapter declareert in zijn klassevariabele available_models. De omgevingsvariabele is dus niet strikt verplicht, hij is de override. Declareer de lijst in de adapter en je hoeft hem nooit te zetten.
Wat wel verplicht is, is dat een van die twee iets oplevert. Komen ze allebei leeg terug, dan weigert de API-modus te starten, logt de reden en gaat er met een exitcode ongelijk nul uit in plaats van kapot op te starten. Dat is de juiste keuze: /v1/models heeft een echte lijst nodig en er is geen veilige terugval, want de naam van de adapter is geen modelnaam. Beter luid crashen bij het opstarten dan een rotlijst met modellen serveren die drie lagen verderop iemands client sloopt.

Twee images, en de kinderen stopten met dezelfde toolchain herbouwen

De basis wordt nu in twee varianten geleverd. psyb0t/aicodebox:latest is de minimale die hierboven is beschreven, en psyb0t/aicodebox:latest-full (met versietag v0.15.0-full) draagt de gedeelde ontwikkeltoolchain: Go, Node, Python, editors, diagnostiek, databaseclients, ops-gereedschap.
Die splitsing bestaat vanwege een duplicatieprobleem dat stilletjes was teruggekeerd. Elk kindimage dat een echte ontwikkelomgeving wilde installeerde dezelfde Go, dezelfde Node, dezelfde Python, dezelfde psql en redis-cli, vanuit zijn eigen Dockerfile en zijn eigen lockfiles. Drie kopieën van de toolchain, die precies zo uit elkaar liepen als ooit de drie kopieën van de HTTP-server. Dezelfde ziekte, een laag lager.
Dus verhuisde het naar de basis. De kinderen erven het door van de full-variant uit te gaan in plaats van het zelf te bouwen, en de opruiming was allesbehalve subtiel: codexbox verwijderde zo’n 15.800 regels aan eigen toolchain-lockfiles, de Dockerfile.full van claudebox verloor 173 regels. make build-full, make build-all en make test-full-image dekken de variant, en CI publiceert eerst het minimale image en bouwt de volle er daarna bovenop.

De Familie

Drie images draaien op dit moment op deze basis, allemaal voorlopig vastgepind op psyb0t/aicodebox:v0.15.1:

  • claudebox: Claude Code. Zijn v2.0.0 was een volledige rebase op deze basis; het levert nu ClaudecodeAdapter aan en erft elk oppervlak.
  • pibox: pi-coding-agent, gericht op welk LLM je maar wilt. Dit is het referentiekind: het gebruikt de basis onveranderd en voegt PiAdapter toe.
  • codexbox: OpenAI’s Codex CLI, via CodexAdapter.

Hoe de agent er werkelijk op komt

Alle drie installeren toevallig hun agent met npm install -g, maar lees dat niet als het recept, het is puur dat claude-code, pi en codex allemaal als npm-globals worden geleverd. De basis heeft geen enkele mening over hoe jouw binary daar terechtkwam.
Het echte contract is twee dingen: het binary dat AICODEBOX_AGENT_BINARY noemt moet op het PATH bestaan, en jouw adapterpakket moet importeerbaar zijn. Meer niet. Wat je daar brengt is jouw zaak:

  • npm: RUN npm install -g @vendor/[email protected]. Node 24 LTS zit al in de basis, precies de reden waarom de drie bestaande kinderen deze route namen.
  • apt: het is Ubuntu 24.04 met sudo en een werkende apt. RUN apt-get update && apt-get install -y your-agent is prima als iemand daadwerkelijk een deb uitbrengt.
  • pip / uv: Python 3.14 zit erin, en uv ook, vastgepind op digest. Zo installeert elk kind zijn adapter toch al: uv pip install --system --break-system-packages --no-deps /opt/yourpkg.
  • Een gecompileerd binary: Go, Rust, wat dan ook. Let op dat de basis geen Go- of Rust-toolchain meedraagt, verwacht dus niet dat go install meteen werkt. Of je brengt de toolchain mee in je eigen laag, of je doet het verstandige en gebruikt COPY --from= vanuit een buildstage zodat de compiler nooit in het runtime-image belandt:
FROM golang:1.26 AS build
RUN go install github.com/someone/[email protected]
FROM psyb0t/aicodebox
COPY --from=build /go/bin/some-agent /usr/local/bin/some-agent
COPY myadapter /opt/myadapter
RUN uv pip install --system --break-system-packages --no-deps /opt/myadapter
ENV AICODEBOX_ADAPTER=myadapter.adapter:MyAdapter 
    AICODEBOX_AGENT_BINARY=some-agent

Dezelfde vijf oppervlakken komen er aan de andere kant uit. De basis komt nooit te weten in welke taal jouw agent geschreven is, en dat is het hele punt, hij roept alleen een naam van het PATH aan en overhandigt de bytes aan jouw parse_output.
pibox is het eerlijke minimum. De agent via npm installeren, het adapterpakket via uv pip install, precies drie omgevingsvariabelen zetten, AICODEBOX_ADAPTER, AICODEBOX_AGENT_BINARY en een PIBOX_IMAGE_VARIANT-label, en er een klein gebrand entrypoint aan meegeven dat PIBOX_* naar AICODEBOX_* aliast. Dat is het hele kindimage. Niet voor niets is het de referentie-implementatie.
De andere twee zijn groter, en dat is het interessante deel. claudebox zet vijf omgevingsvariabelen, codexbox vier, en beide leveren bovenop de adapter nog een launcher-script mee. Niet omdat de basis lekt, maar omdat elke agent zijn eigen staatsprobleem meesleept waarvan een agent-agnostische basis niets mag weten:

  • claudebox zet CLAUDE_CONFIG_DIR zodat Claude Code .claude.json en zijn credentials op de bind mount schrijft in plaats van in een niet-gemounte $HOME, anders doe je bij elke hercreatie van de container de themakeuze en de login opnieuw. Alleen claudebox weet dat de lading Claude Code is, dus alleen claudebox kan dat zetten.
  • codexbox zet CODEX_HOME om dezelfde reden (een API-sleutel of een OAuth-token van een ChatGPT-abonnement dat een hercreatie moet overleven) en moet er tijdens de build een mkdir en een chown op doen, want codex crasht bij het opstarten als CODEX_HOME naar een map wijst die niet al bestaat.
  • Beide wijzen AICODEBOX_AGENT_BINARY naar een launcher-script in plaats van rechtstreeks naar het agent-binary. Dat van claudebox herstelt de interactieve defaults die de agent-agnostische basis bewust heeft laten vallen: --continue met een terugval, --permission-mode bypassPermissions, en de always-skills injectie via --append-system-prompt. Servermodi omzeilen de launcher volledig en bouwen argv via de adapter.

Dat is de echte vorm van deze abstractie, en ik beschrijf hem liever nauwkeurig dan te doen alsof elk kind vier regels is. De basis bezit alles wat niet agentspecifiek is. Wat er in elk kind overblijft is precies wat dat wel is: de adapter die weet dat dit binary -p wil terwijl dat ding --prompt wil, dat de een stream-json uitzendt terwijl de ander platte tekst print, plus welke configuratie-persistentiegril die specifieke agent je ook oplegt.
Al het andere, de API, de OpenAI-shim met zijn schema-retries en tool calling, de Telegram-bot, de cron-scheduler met zijn historie, de MCP-server, wordt geërfd. Je fixt een bug in de basis, je tagt, je tilt de pin op in drie kinderen, klaar. Wat het hele punt is, en de reden dat claudebox v2 überhaupt bestaat.

Er Eentje Draaien

docker run --rm -p 8080:8080 
  -e AICODEBOX_API_MODE=1 
  -e AICODEBOX_API_MODE_TOKEN=$(openssl rand -hex 16) 
  -e AICODEBOX_AVAILABLE_MODELS=fast,smart 
  -v "$PWD/workspace:/workspace" 
  your/child-image:latest

Daarna praat je ermee alsof het OpenAI is, of ram je rechtstreeks op /run, of richt je er een MCP-client op, of raak je HTTP helemaal niet aan en laat je de cron het gewoon om negen uur ‘s ochtends afvuren.

De container-herstartlus die op helemaal niets leek

De beste bug die dit ding heeft opgeleverd, omdat elk signaal dat hij je gaf een leugen was.

Symptoom: in api-modus herstart de container periodiek. Geen crash, geen OOM, geen stack trace. De exitcode is 0. De lus correleert met verzoekactiviteit in plaats van met geheugen of uptime, wat je volledig de verkeerde kant op stuurt.

Wat er werkelijk gebeurde: het subproces van de agent deelde de procesgroep van wat de server ook had gestart. In api-modus is de server PID 1. Dus wanneer de CLI van de agent, of welke tool de agent zelf ook had gebaard, een SIGTERM of SIGINT aan die gedeelde groep afleverde, ving uvicorn hem ook op en sloot af. Netjes. Bewust. Exit 0, klus geklaard.

En je zag het nooit, omdat uvicorn.run(..., log_config=None) zijn eigen afsluitregels onderdrukt. Een keurige, stille, volkomen correcte afsluiting die het herstartbeleid van de container vervolgens weer ongedaan maakte, keer op keer.

De reparatie is één flag in aicodebox/shared/runner.py, start_new_session=True bij het starten van de agent, zowel op het synchrone run()-pad als op het streamingpad run_stream(). Eigen sessie, eigen procesgroep, signalen reizen niet meer omhoog.

Regressietests bespioneren beide spawn-primitieven en controleren dat de flag wordt meegegeven, zodat hem op een van beide plekken laten vallen de build sloopt in plaats van stilletjes een lus terug te brengen die niemand uit de logs kan lezen.

Pin v0.14.5, niet v0.14.4. Dezelfde reparatie, maar pyproject.toml had van v0.14.1 tot en met v0.14.3 op 0.14.0 gestaan terwijl de Makefile de image-tag eruit afleidt, en v0.14.5 is de herpublicatie waarin het bestand en de tag het eindelijk eens zijn.

De Slotsom

De agent die je vandaag gebruikt is niet de agent die je over een jaar gebruikt. Dat is geen pessimisme, dat is gewoon het releasetempo, dit vakgebied herschrijft zichzelf om de paar maanden, en alles wat je strak gekoppeld aan één CLI bouwt is steigerwerk met een houdbaarheidsdatum.
Dus koppel er niet aan. Zet de oppervlakken in een base image, zet de agent achter een adapter met twee methodes, en als de volgende landt schrijf je veertig regels in plaats van er negenhonderd te forken. Ik heb het inmiddels drie keer gedaan. De derde kostte een middag.
github.com/psyb0t/docker-aicodebox
Onder WTFPL-licentie, want een base image dat specifiek bestaat zodat je het niet hoeft te forken zou een stom ding zijn om een restrictieve licentie op te plakken.