run_code, to the investigative loop (loop = "investigative", code in internal/agent_research/investigate/ and investigate_core/), so that the agent can run a small piece of Python while it researches: compute from numbers it found, parse or filter a long text, cross-check datapoints, sort and join what it collected. The code runs in Pydantic Monty, a sandboxed Python interpreter written in Rust, in a sidecar process of the gligabao process that runs the agent. The functions the code may call back into gligabao are Go functions, declared in one registry (below).
The simple loop (loop = "simple") is not touched. The agent as it works today is in deep-research-agent.md, the definitions in agent-defs.md, the run in research.md.
Why
The model is bad at arithmetic, at exact counting and at reading 200 rows of a table in its head, and every row it reads costs context. The loop already fights context bloat (forced extraction, eviction); code is the other half of the answer: the model writes a few lines, the sandbox does the exact work, and only the short printed result enters the prompt. It also makes datapoint cross-checks (unit conversion, ratios, “do these two figures agree within 5%”) deterministic instead of a judgement.Why Monty
- It is a sandbox by construction: no file system, no network, no environment, no imports of the host’s modules unless the host hands them in; the only way out is the external functions the host registers. This matches the loop’s rule that the code decides what may happen and the model decides content.
- It starts in microseconds and has resource limits (duration, heap memory, recursion depth), so a model-written infinite loop or a huge allocation ends the call, not the process.
- It runs in worker subprocesses, so even a crash of the interpreter (stack overflow, allocator abort) cannot take the host down.
- It supports external functions: when the code calls one, the interpreter pauses, the host runs it and resumes the code with the result. That is the mechanism for host functions.
0.0.x) and its subset of Python is limited (no arbitrary third-party packages, a partial standard library). The prompt must say so, and the version is pinned (Dockerfile).
Constraints
- Functional style (mandatory,
AGENTS.md): the host-function registry is an immutable value; a function’s spec, its argument decoding, the generation of the stubs and of the prompt text are pure functions; the call into the sandbox, the process supervision and the host functions’ effects are at the edge. The pure core (investigate_core) gets no new dependency on a process or on I/O (purity_test.gomust still pass). - Minimal: one tool, one sidecar, one registry. No second interpreter, no package installer, no persistent code workspace between runs.
- Opt in: off unless the agent definition turns it on. A server without Monty installed starts and works; an agent that needs code and has none fails to start with a clear message (Failure modes).
- Code cannot bypass the loop’s rules. It reads state; it does not change the frontier, the notes or the claims (the mutating tools stay model tools, so the gates, counters and breakers keep working). What it fetches goes through the same limits, domain rules, cost accounting and attempt log as a direct tool call.
What the agent sees
One new tool, offered in the turns where the other research tools are offered (the normal exploration turns and the planning turn), not in the extraction, consolidation and synthesis turns (synthesis has no search tool, and the report must come from the notes). It is added through theExternal list of the loop’s Config, the way web_search and fetch_page are, so OfferedTools and dispatch need no new concept.
max_output_chars, never the raw protocol):
error: <kind>: <message> with the traceback line numbers (kinds: syntax, type, runtime, timeout, memory, crashed, host_function, unavailable). An error is a normal tool error: the model reads it and goes on (it may fix the code).
Architecture
Why a sidecar, and which process owns it
Monty has Python and Rust APIs and no Go one. The sidecar is a child process of whichever gligabao process runs the agent:gligabao serve (the agent runs in the server) or gligabao_agent (the standalone binary; the CLI daemon is a serve). Each process that runs an investigative agent with code enabled owns its own sidecar:
- Started lazily, at the first
run_codeof the process (not at startup), so a server whose agents never use code pays nothing. One sidecar is shared by all the runs and sub-processes of that process, with a bounded worker pool (monty_workers). - Supervised: if the sidecar dies, the next call restarts it (at most a few restarts in a row, then
unavailableuntil the process restarts, so a broken install does not spin). In-flight calls of the dead sidecar fail withcrashed. - Stopped with its owner: closing its stdin ends it; on
serveshutdown and whengligabao_agentexits the supervisor also terminates and then kills the process group, so no orphan stays. - Not the training sidecar. The existing sidecar is a separate HTTP service for Modal. This one is a local child process, with no port and no network surface: the pipe is private to the parent.
pydantic_monty: Monty pool, checkout() sessions, feed_start snapshots for external calls, ResourceLimits); the monty subprocess wire format (framed protobuf, the monty-proto crate) is an internal protocol of a 0.0.x project. Speaking it directly from Go (one less runtime in the image) is a possible later change that stays behind the same internal/monty interface (Leftovers).
The bridge protocol
One JSON object per line, UTF-8, both directions. Go is the client; every message carries theid of the run_code call it belongs to, so calls run concurrently on one sidecar.
Go to bridge:
Bridge to Go:
protocol is an integer; a mismatch on ready is unavailable with the two versions in the message. The bridge file is embedded in the Go binary (go:embed) and started as python3 -c <script> (or the interpreter in GLIGABAO_MONTY_PYTHON), so there is no loose file to deploy. Values cross as JSON (Monty’s conversion covers None, bool, int, float, str, list, dict, tuple); anything else is a host_function error naming the type.
Host functions
“The Go functions exposed to the Monty environment” are one declared, typed, immutable registry. Nothing is callable from the sandbox that is not in it, and the registry is the single source for four things: the callable names, the argument decoding, the Python stubs sent to the bridge (for name lookup and the optional type check), and the function list in the tool description. Packageinternal/monty/hostfn (no dependency on the process code):
Env is the read-only view a handler gets for one run_code call: a snapshot of the loop State (frontier, notes, claims, challenges, as of the start of the call), the budgets, and the narrow interfaces to the effects it may use (the fetch and search tools, the knowledge query). It never exposes the DB, the session or the model client. A handler is a function of (Env, Args); the effects it performs are the ones of its Class, executed by the loop’s existing paths.
The first set
Grouped by area; all arguments and results are plain JSON-like values.
What is not exposed in this version, on purpose: any function that changes the frontier, the notes or the claims (
add_question, resolve_question, notes_append, record_claim, …), remember, the file system, the clock, randomness, the environment, the sub-agent spawn. The model records what it learned from the code through the normal tools, so the loop’s gates and counters see it. Adding a function is one Spec plus its handler and a test; it needs no change to the bridge or to the loop.
Rules every call follows
- Budgets.
max_host_callsperrun_codecall and per run ([code], below); afetchclass call also counts against the run’sfetch_url/web_searchmax_callsand its cost against the cost cap, exactly as if the model had called it. Over a budget the function raises a Python exception inside the sandbox (BudgetExceeded: ...), which the code can catch or which ends the program. - Errors are values. A handler error becomes a Python exception with the message in the sandbox (
HostError), never a crash of the bridge. - Cancellation. A host call runs under the context of the run; a stopped run cancels the call and the program.
- Size. A result over
max_result_charsis refused with an error that says to ask for less (fetch_page(url, max_chars=...)), so the sandbox cannot be used to pull a megabyte into a variable and then the context. - Order. Calls from one program are serial (no async externals in v1).
Agent definition
A new optional[code] table in the agent definition, accepted only with loop = "investigative" (an error with the simple loop, like [investigate]: not a silent no-op):
functions narrows the registry per agent (a research that must not fetch from code lists only the read ones); a name that is not in the registry is a startup error, with every other problem of the file in the same message. A research request can lower the numbers through the investigate-style override only if a need shows up; v1 has no request option.
Server-wide settings (gligabao.toml, the matching serve / gligabao_agent flags and environment variables):
A definition with
[code] enabled = true on a server where the sidecar cannot be found is a startup error for that definition at load time (a cheap check: the interpreter and pydantic_monty import, the binary exist), so it is found at boot, not in the middle of a run.
Changes in the investigative loop
All at the edge; the pure core is unchanged except for the data that describes the tool.investigate_core/prompt.go: a short block in the loop’s instructions, only when the tool is offered: whatrun_codeis for, that it is stateless, that it cannot change the frontier, that exact numbers should be computed rather than estimated, and that a computed datapoint is recorded as a claim with its sources (the code is not a source).investigate/loop.go:run_codeis anagent.Toolincfg.External, built byFromDefwhen[code]is enabled, so it reusesrunExternal(usage and cost draining, attempt logging for raw tools).run_codeis not a raw tool (it is not an attempt at a question by itself); its innerfetch_pageandweb_searchcalls are logged as attempts through the host function path, with the tool name of the real tool.- Context: a
run_codeoutput is capped bymax_output_chars, so the forced-extraction threshold rarely applies; if it does, the existing rule is used as is. - Trajectory (
atif): the step of arun_codecall records the code, the printed output, the stats and, as nested calls, the host functions it made, so a run can be audited afterwards. The admin UI’s investigation view does not change in this version. - Breakers and cost: the sandbox costs no tokens; its host calls are counted as above. The wall time of
run_codecounts against the run’s own time limit if the definition has one. Arun_codethat fails does not count as a wasted attempt of a question.
Dockerfile
The runtime stage ofDockerfile installs Monty: the Python bridge client and the worker binary, pinned. The final image stays Debian slim; the Go binary is still static.
pydantic-montyis the metapackage: it installspydantic-monty-client(thepydantic_montymodule) andpydantic-monty-runtime(themontybinary, put inbin/of the venv). Python 3.11 of Debian bookworm satisfies the>=3.10requirement.- The venv is world-readable and the
gligabaouser (10001) only runs it: no write access is needed at run time (PYTHONDONTWRITEBYTECODE=1can be set to keep it so). - The version is a build
ARG, so an upgrade is a one-line change; the bridge’sprotocolcheck and the integration test (Tests) catch an incompatible release. - The comment above the
ENVline in the Dockerfile says what the venv is for, in the style of the other lines. Thedocker rundocumentation in the docs (the README / deploy notes that mention the image) says that the image includes the code sandbox and that it is only used by agents with[code] enabled. Dockerfile.sidecar(training/Modal) is unchanged: the code sandbox does not run in Modal, since an investigative agent is local only.
go install, the release binaries) needs pip install pydantic-monty somewhere and monty_python / monty_bin pointing at it; the docs say so, and gligabao doctor-style output (the startup log) prints whether the sandbox is available.
Failure modes
Security notes
- The sandbox has no I/O of its own. Its only reach is the registry. Network and file access exist only as
fetchclass functions that go through the existing domain rules (allowed_domains/blocked_domains, redirects checked). The injected content of a fetched page can steer the model into writing code, so nothing in the registry may write outside the run (this is why the first set has no mutating orrememberfunctions). - The sidecar runs as the same OS user as gligabao, with no extra privileges and no listening port. Memory and CPU are bounded by Monty’s limits and the worker cap; in Docker the container limits are the outer wall.
- The code and its output are stored in the trajectory of the run; they are subject to the same retention as the rest of it.
Tests
- Pure (no process): the registry (
Newrejects duplicate or invalid names;StubsandDescribegolden tests; argument decoding errors; thefunctionsallow-list narrowing), the[code]loader checks (rejected with the simple loop, out-of-range numbers, unknown function), the output cap and the result formatting. - Protocol (a fake bridge, a small Go test binary that speaks the JSON lines): ordering, concurrent runs,
call/resultround trips, cancel, a bridge that dies mid-call, a protocol mismatch, restart limits. - Loop: with a fake
run_codeinExternal, the tool is offered in exploration and planning turns and not in extraction, consolidation and synthesis; afetch_pagemade from code counts againstmax_callsand the cost cap and is logged as an attempt;purity_test.gostill passes. - Integration (skipped when
monty_python/MONTY_BINare not available, like the other tests that need an external tool): real Monty: arithmetic, an infinite loop ends withtimeout, a memory bomb ends withmemory, a host function round trip, acrashedworker is replaced. - Docker: a build check that
/opt/monty/bin/monty -c "print(1+1)"runs as thegligabaouser.
Implementation order
internal/monty/hostfn:Spec,Registry, the read-only functions, the stub and description generators, all with their pure tests.- The bridge script and
internal/monty: the client, the protocol types, the supervisor (lazy start, restart bound, shutdown), tested against the fake bridge, then against real Monty. - The
[code]table ininternal/agent/agent_defs(format, loader checks, the startup availability check) and the server settings and flags inserveandgligabao_agent. run_codeas anagent.Tooland its wiring inFromDefandinvestigate.Config.External; the prompt block; the trajectory recording.- The
fetchclass functions (fetch_page,web_search,kb_query) with the budgets, cost accounting and attempt logging. - The
Dockerfilechange and its build check. - An example agent in
build/agent_defs/(an investigative agent with[code] enabled = true), an eval or a few scripted runs comparing a numeric-heavy topic with and without code. - Docs and release: update agent-defs.md (the
[code]table), research.md, deep-research-agent.md and the deployment notes; thedocs/roadmap/ROADMAP.mdrow;VERSIONandCHANGELOG.md(a minor bump: new tool, new config and a new dependency of the image, with a note that nothing changes for an agent that does not set[code] enabled).
As built
Where the code is:internal/monty/hostfn (registry and the first set), internal/monty (bridge.py, embedded; Sidecar, the supervisor and client; Runner interface), internal/agent_research/investigate/code.go (the run_code tool, built by investigate.Run from Config.Code, not in FromDef: it needs the run’s state and its fetch tools), agent_defs.Code (the [code] table), investigate_core.ToolRunCode and the prompt block, Manager.Monty / Manager.Sandbox.
Differences from the plan above:
- Bridge: the bridge uses
feed_runwithexternal_lookup(one thread per run, one worker session per run) instead offeed_startsnapshots; the host call blocks the thread until Go answers. The list of allowed functions and the stubs travel in eachrunmessage (functions,stubs), so agents with differentfunctionsshare one sidecar;hellocarries the full registry.monty_workersandmonty_request_timeout_sreach the bridge as the environment variablesGLIGABAO_MONTY_WORKERSandGLIGABAO_MONTY_REQUEST_TIMEOUT. - Settings:
monty_python,monty_bin,monty_workersandmonty_request_timeout_sare ingligabao.toml(andMONTY_BIN/GLIGABAO_MONTY_PYTHONin the environment, whichgligabao_agentuses); there are no CLI flags. - Availability check: it is made when a run of an agent with
[code] enabledstarts (the run fails with the missing piece named), not when the definitions load. - Budgets:
max_host_callsis perrun_codecall only; thefetchclass functions go through the agent’s own tools, so their limits, cost and attempt log apply as for a direct call.fetch_pageandweb_searchappear in the function list only when the agent has the tool, andkb_queryonly when it has the knowledgequerytool. - Trajectory: the step of a model call that used
run_codehasextra.code_runs: for each call the code, the output,wall_ms, the host function calls (function, args, kwargs) and thelog()lines. - Example:
build/agent_defs/numeric_investigator.toml.
Leftovers
Left out of this plan on purpose; record as leftover tasks when the work lands:- Speaking the
monty subprocessprotocol (ormonty-serverover WebSocket) directly from Go, to drop Python from the image. - State that survives between
run_codecalls of a run (a REPL session per run), andmounted read-only data (the run’s fetched pages as files). - Code in the synthesis phase, in sub-agents, in the simple loop and in
modalruns. - Mutating host functions (recording a computed datapoint as a claim straight from the code) with the gates applied.
- Showing the code calls in the admin UI’s investigation view.