Many agents write code as an action. The harder question is where that code runs and what survives afterward. A single tool that evaluates a string and returns stdout works for a short task, but becomes expensive and unreliable over a day of work.
A one-shot executor may load a 2 GB export into a dataframe and close the sandbox after printing one summary. The next question requires loading the same file again. Ten minutes of work can go into rebuilding state that the executor deliberately discarded.
A permanently shared interpreter creates the opposite problem. A variable from an abandoned experiment can shadow a later one, and an old import can change a new task. Independent work inherits whatever the previous task left behind.
Three modes, not two
Both behaviours are useful, so execute_code takes a state_mode on every call. stateless uses a fresh sandbox that closes when it returns. stateful uses a named session where variables and imports survive.
read_only copies an existing session into a temporary sandbox and discards every change. The model can inspect or transform the state without damaging the original.
Read-only mode makes an expensive session safe to inspect. Say a task has spent four minutes building a dataframe and the model now wants to try a reshape it's only about 70% sure about. Under stateful a bad guess corrupts the dataframe, and the recovery path is to rebuild it. Under read_only it tries the reshape, reads the result, and the original is untouched because it was never reachable in the first place.
The second choice: where code runs
The same state choices apply to different execution backends. Code may run in the current Python process, in another interpreter with different packages, in a shell, or on another machine.

We represent each execution with coordinates for language, machine, state mode, session, and environment. The model supplies those properties and the runtime resolves them. Sessions are keyed by language, virtual environment, and session id. A Python session 0 can therefore coexist with a Bash session 0.

Sessions you can name and look inside
Sessions have integer ids, but the model is encouraged to use names. It can reason about session_name="audit" more reliably than session_id=3after twenty intervening steps. A registry maps names to keys, with a limit of twenty live sessions per actor. The model can call list_sessions() and inspect_state() instead of guessing what still exists.
Every act() call already has a Python sandbox for its plan. Python session 0 refers to that sandbox rather than a separate pooled process. Higher session ids create deliberately separate namespaces. This gives one layer of isolation per task and another between named sessions inside it.
Virtual environments per function
The last coordinate is the environment, and it exists because of a mundane problem that has no clever solution. A function someone stored six months ago pins an old version of a library. A function written last week needs the new one, where the API changed. Both are legitimately useful, both live in the same library, and no amount of agent intelligence resolves that conflict, because it's a packaging problem.
So a stored function can declare its own environment. The environment is a pyproject.toml held as data; the first time something needs it, uv builds it and it's cached on disk keyed by content, so the second call is a subprocess spawn rather than a dependency resolution. For stateful work the subprocess is held open in a pool, so an expensive interpreter start is paid once.
Code inside a dedicated environment still writes await primitives.contacts.ask(...). The call travels over a JSON-line channel to the parent process, runs there, and returns its result. The subprocess keeps dependency isolation without losing access to the surrounding runtime.
There's deliberately no dependency negotiation between environments, so two functions wanting incompatible versions get two environments and never meet. Isolation is cheap, whereas solving the general version-conflict problem isn't, and I don't really think it's ours to solve.
Where the constraint is real
Remote surfaces, meaning the assistant's own VM or a machine the user has linked, are stateless one-shots with no sessions and no venvs. That's less of an omission than it looks, because a persistent session implies a process the runtime is confident is still alive and still yours, and that guarantee is much harder to make across a network boundary and a machine somebody might close the lid on. Rather than offer a session that silently evaporates, the tool rejects the combination with an error that says which arguments to drop.
Failures the interface handles
The interface also handles several mistakes that models make repeatedly.
primitives is the object that exposes managed capabilities. If generated code assigns another value to that name in a stateful session, later calls lose access to the real object. An AST pass rewrites assignments to a harmless local while leaving reads unchanged. The injected global therefore survives without relying on a prompt warning.
Argument validation returns structured errors rather than raising, so you get a dict with a message and a suggestion field naming the arguments to change. Asking for read_only without a session, or a session on a remote surface, comes back as something the model can read and immediately correct on the next call, which works a lot better than throwing an exception at it.
Python execution keeps REPL semantics, so the last expression becomes the result without an explicit return. If a pooled subprocess dies, the pool rebuilds it and retries once. Any state from that process is gone, and the result reports that loss.
Why one tool
We could expose five tools: run_python, run_python_stateful, run_in_venv, run_shell, run_remote. That surface grows with every combination of state and backend. Its documentation drifts, and the model must infer differences from tool names.
One tool with independent arguments lets the model describe the work instead. It decides whether state must persist, whether special dependencies are needed, and where the code should run. Models handle those properties more reliably than a growing menu of executors.
The basic REPL took an afternoon. Most of the work went into keeping the state and backend choices independent, then making invalid combinations fail with an explanation the model can act on. Remote execution still lacks persistent sessions because we cannot guarantee that the remote process remains alive. I am not yet sure whether that limitation should remain an explicit boundary or become another supported coordinate.
Where to look
All open at github.com/unifyai/unify:
- The
execute_codetool, its docstring, and the session registry:unify/actor/code_act_actor.py - Mode resolution, validation errors, and the shadowing guard:
unify/actor/execution/session.py - Virtual environments, the subprocess pool, and the RPC boundary:
unify/function_manager/function_manager.py - Execution surfaces:
unify/actor/execution/surface.py


