exec and wait: How an Unfinished Program Ends
Rather than twenty tool calls, let the model write a stretch of JavaScript. Where it runs, what it can do, and what happens if ten seconds isn’t enough — this lesson is the two ideas behind those three questions.
- The model can put a pragma on the first line, declaring how much yield time this run getsdescription.rs L22
- The exec entry turns that millisecond count into “observe once when the clock hits”service.rs L77
- A budget over ten seconds gets one extra second of grace, then a server-side capservice.rs L198
- When the timer hits, the buffered output is handed over as one packet, and the buffer is clearedcell_actor/mod.rs L242
- The yield is translated into a sentence the model can read, with a cell idcode_mode/mod.rs L283
- The model comes back with the id to continue — and can also change the budget, cap length, or stop itwait_handler.rs L24
- The script finishes, returns a result, and closes the cellruntime.rs L24
Read five files and summarize: ordinary tool calls take five full round trips. Each trip pushes the whole file into context, and the next turn the model re-reads the swollen history. What’s expensive is the rhythm of the trips, not the tools.
So just let the model write a program. The trouble: nobody reviewed a line of it — the model writes and hands it over live. Give it a runtime that can read files and hit the network, and you’ve handed over the host’s whole capability set.
Codex gives the model two tools, exec and wait. exec takes a stretch of JavaScript and evaluates it as an async module in a fresh V8 isolate. Every tool hangs off the global tools object, names normalized to legal JS identifiers, so you write await tools.exec_command(...). Loop if you want, branch if you want; intermediates stay in variables; only what you hand over comes back to the model.
The point is how thin this runtime was carved. The tool spec tells the model, straight, what it does not have:
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
openai/codex repo; verified against codex-rs/code-mode-protocol/src/description.rs, commit 4f39251a01, verified 2026-08-22. Code blocks keep the original source. This text itself is the tool spec sent to the model.No Node, no filesystem, no network, not even console. Those weren’t forgotten — they were withheld. Any side effect has one road: tools. That road still has the same approvals and sandbox: the dialogs still pop, the blocks still block.
A side benefit: the surface you have to audit shrinks. If the isolate could read files, this layer would need its own file-permission set. Now it can do nothing, so permission stays one layer down — you don’t write the code twice.
Trips are expensive, batching is cheap — an old ledger. Databases have bulk writes; RPC frameworks accumulate batches. One model sample costs far more than one network hop, so collapsing N trips into one pays even bigger.
The subtraction half is more general. Give untrusted code the smallest room you can, so even if it wants harm it has no interface — that’s the shape of secure design, and it doesn’t depend on V8. Swap the language or the sandbox and the question is the same: which few capabilities does this code actually need, and can the rest be withheld entirely?
The program needs three minutes. What do you set the timeout to?
Set it to three minutes and the user stares at silence, with nowhere to shout stop. Set it to ten seconds and long work never finishes — worse, the first nine seconds vanish with it, and the model gets one timeout and has to start over. Both directions are wrong. The bug is treating “not done yet” as failure.
Codex makes a running script a thing with an identity: a cell. When yield_time_ms hits, the cell doesn’t die — it hands over the buffered output as one packet, clears the buffer, and keeps running. exec then returns a sentence: the script is still running, here’s the id.
With the id, the model has three choices: call wait to buy more time; pass terminate: true to stop it; or go do something else first. wait returns only output since the last yield, because the buffer was cleared on handoff — the same text doesn’t occupy context twice.
A small detail shows what the designers were thinking. When yield time is over ten seconds, Codex adds one extra second of grace before it actually observes.Source:codex-rs/code-mode-runtime/src/service.rs lines 198–210A script that finishes right on the boundary doesn’t burn an extra round trip over a few milliseconds.
One more asymmetry is worth a note. In the spec sent to the model, wait has four parameters: cell id, yield time, return-length cap, and whether to terminate. The protocol request struct only carries the first two. The last two stop at the handler: terminate takes another path, and the length cap is applied after the result arrives. Reading the source, those two layers are easy to mash into one.
Making “not finished” a first-class state is the shape of long-task APIs. HTTP has 202 plus polling; job queues have a job id plus poll; a big-file export also hands you a number first. The common move: don’t force the caller to choose between “wait forever” and “treat as failure” — give them a handle they can ask again.
On an Agent, that handle is worth one more layer. After seeing intermediates the model can change its mind — the first four steps look wrong, so it terminates, instead of sitting through the next eight minutes. Control returns to the side that can think.
Timeout: DeepSeek Harness chooses to kill
DSH’s run_code keeps two ledgers. One books busy time by polling the worker’s event-loop utilization — hot loops can’t hide, and idle waits on slow tools aren’t billed unfairly. The other books wall clock and kills the worker when it hits. Defaults: 60,000 ms and 600,000 ms.
The cost is clear: one run_code must finish inside the budget; timeout is failure; there is no first-class “same program keeps running.” What you get is a simple implementation — the host doesn’t keep a pile of live cells. Codex flips it: the model must learn a wait protocol, and a cell occupies session resources until it finishes, is stopped, or the session ends.
State: a disposable world, or a drawer you keep
DSH’s design notes are blunt: the world the program lives in dies with the worker — no pooling, no cross-run state. Anything for the next run goes into the tool result or a workspace file. The upside: every run is a clean new world, easy to replay when something breaks.
Codex gives store and load: multiple execs in one session can share data; across sessions they can’t see each other. Orchestration is easier; cleanup falls back on you. The drawer has no per-entry size cap, only refuses values that won’t serialize to JSON, and has no TTL — it waits for the whole session to end, then goes with the runtime.
How to price a yield budget so you don’t lose
A program needs forty seconds; the default yield budget is ten. Walk it: how many waits does the model send, does each return the full output or only the new stretch, and why raising the default to thirty seconds is not always cheaper.
Follow-up: if at second 35 the program puts a large object into store, then the model decides to terminate, when is that object cleared, and who owns its size?