Code mode
Code mode gives the model one more tool, run_code. The model writes
JavaScript that calls the agent's tools, and gets back only what the script
returns or logs.
Use it when the model chains many calls, runs calls that do not depend on each other, or reads large results to pick out a few values. Each call in a script costs no model turn, and its result stays out of the conversation.
Turn it on
[agent.assistant]
model = "claude-sonnet-5"
codemode = "javascript"A worker sets codemode: "javascript" on the agent config. JavaScript is the
only language so far.
What the model writes
run_code takes code, the body of an async function.
const prs = await tools.github__list_prs({ repo: "acme/api", state: "open" });
const stale = prs.filter((pr) => Date.parse(pr.updated_at) < Date.now() - 14 * 864e5);
const reviews = await Promise.all(stale.map((pr) => tools.github__get_reviews({ number: pr.number })));
return stale.map((pr, i) => ({ number: pr.number, reviews: reviews[i].length }));Code mode is all or nothing. The model gets one tool, run_code, and reaches
every other tool from a script. The agent has no call_tool or attachment
tools, and a direct call to any tool but run_code is refused with an error
that says to call it from run_code.
The description of run_code declares each tool the script can call in
TypeScript: the first line of its description as a comment, its input type and,
when the tool declares an output schema, its result type. Each definition a
schema refers to with $ref becomes a named type, declared once, so a
recursive schema stays finite. The declarations stop at 12 KiB. The
description holds nothing that changes while a session runs, so the cached
prefix holds.
A script finds the other tools, past the budget or deferred, with
tools.tool_search. The conversation lists each skill with the tools it names,
in the system prompt on the first call. A skill added later is listed when it
is, after what the model has read. In code mode a skill holds back no tools:
its tools says which tools it is about.
A script finds tools with tools.tool_search({ query }). It answers as text:
the declarations of the tools that match, each with its whole description, then
the other matches by name, then the skills that match, with how to load them. A
query of exact tool names, as "get_audience, find_customers", answers just
those tools. A block of declarations, in a search answer, a skill's answer or
the description of run_code, holds at most 12 KiB, but its first tool is
always declared, so a search for a tool's name always finds its declaration.
The model reads the answer, then calls the tools in its next script. A call to a tool that does not
exist throws an error that names the closest tools.
A call returns the tool's result, parsed when it is JSON. A call that fails
throws an Error with the tool's message.
What a script can call
A script can call the agent's worker tools, connector tools, client tools,
subagents, skill and tool_search. A call that needs approval waits for a
person, and a client tool waits for the client, as a direct call does. A
subagent call returns { session, result }, as a direct call does. A script
calls tools by name, and reads files with files. A call to run_code, or to
a tool that does not exist, throws, and the script can catch it. A call is
checked against the tool's input schema before it runs, and its result
against the tool's output schema, as a direct call is.
await tools.skill({ name }) loads a skill. It answers with the skill's
instructions, then the declarations of the tools it names. The script logs the
answer, and the model can then call those tools in its next script without a
search. When the agent searches its skills, tools.tool_search finds them too.
A skill's other files are files. A script reads them by <skill>/<path>, such
as files.text("runbooks:respond/references/escalation.md"), and shows them
with files.show. files.list lists them with the skill they belong to, as
skill. The script's first step records the blobs that hold the skills' files,
and every later run reads those, so a plugin that changes while the script runs
does not change what it reads.
What reaches the model
The run_code result is text: Script finished., what the script printed,
then the value it returned, a string as itself and any other value as JSON.
Script finished.
Printed:
3 stale PRs
Returned:
[{"number":12,"reviews":0},{"number":15,"reviews":2}]Past 24 KiB, the middle is cut out with a note, so the start and the end stay.
A script that throws gives Script failed., the error and the lines of the
script in its stack, then what it printed. Then comes each file the script
showed.
files.show(id) puts a file in front of the model: an image, PDF, audio or
video as itself, and any other file as its text. A script shows at most 4
files, 64 KiB of file text and 2 MiB of media, which bounds what a file can
cost in context. Text the script makes is printed with console.log.
How a run works
When a script stops on calls, the engine asks your worker about them with a
codemode.calls trigger, as it asks about the model's calls on llm.finished.
{ "type": "codemode.calls",
"id": "<run_code call id>",
"tool_calls": [
{ "id": "call_s1", "type": "function",
"function": { "name": "weather", "arguments": "{\"city\":\"Oslo\"}" } }
] }proposed runs each call with tool.call, or starts a subagent with
subagent.spawn. When a call needs approval, it asks a person about that call
first, and the rest wait. When a subagent finishes, subagent.finished
proposes its answer as the call's tool.result. Return proposed, or answer
each call by its id: tool.call runs it, and tool.error refuses it with
your message. A call that no answer runs or refuses fails, and the script can
catch the error. A worker that returns proposed for triggers it does not know
needs no change.
Each call a script makes is a tool call in the session log, under the
run_code call. It runs as a direct call does, with the same retries and
deadlines. The model does not see it in the transcript.
The engine does not keep a script running while it waits. When the calls a script made have settled, the engine runs the script again from the start. A call with a recorded result gets that result at once. The run stops at the first call with no result, and the engine makes that call. A restart loses nothing, because the log holds every result.
So a script must make the same calls each time it runs. Date and
Math.random give the same values on every run. A run that makes a different
call fails.
run_code has no deadline of its own. Its calls keep theirs, and the script's
limits bound what it runs. An interrupt holds
a script: no step runs, and no call it asks for goes out, until the resume.
Files
A script reads the conversation's files by the id in their [attachment …]
line. The bytes never enter the model's context: the script reads them, and
only what it returns or prints goes back.
const totals = {};
for await (const row of files.csv("sales.csv")) {
totals[row.region] = (totals[row.region] ?? 0) + Number(row.amount);
}
return totals;| Call | Gives |
|---|---|
files.list(), files.stat(id) | { id, mime, size } of each file, or of one, and skill for a skill's file |
await files.text(id) | The whole file as text, up to an eighth of memory |
files.show(id) | Nothing; the model sees the file when the script ends |
files.csv(id, { header, delimiter }) | Rows: objects keyed by the header, or arrays with header: false |
files.lines(id) | Lines, without their line ends |
files.jsonl(id) | One JSON value per line |
files.chunks(id, { size, offset }) | Text in pieces of about size bytes, from byte offset |
files.xlsx(id, { sheet, header }) | Rows of an xlsx sheet, the first by default, like files.csv. Numbers and booleans stay as they are, dates become ISO strings, and empty cells are null |
await files.sheets(id) | The names of an xlsx file's worksheets |
Each stream takes { limit, offset }, counts of records to read and to skip,
and break ends it. A CSV or xlsx header, blank JSON lines and empty sheet
rows are not counted.
files.chunks counts its offset in bytes. The
files are the attachments on the branch where run_code was called.
The script's process decodes the files, so decoding counts toward its CPU and memory. Its runner reads the bytes: the process has no file system or network, and never sees where a file is stored. A file's bytes never change, so the log records no read: each run reads its files again. Read files before calling tools, so the reading happens once per run.
A file that is gone throws in the script. A store that fails sends the step back to its queue, as a runner that is down does.
RuntimeConfig::script_blob_cache puts a disk cache in front of the store that
scripts read from. It is worth it when the store is remote, as object storage
is.
Sandbox
Each run of a script is a new process that the engine starts from its own
binary. Before the process reads the script, it closes every file it
inherited, starts with no environment, drops to nobody when it runs as root,
and locks itself down: on macOS with a sandbox profile that denies everything,
and on Linux with a seccomp filter that has no call to open a file, open a
socket or start a process. A process that cannot lock itself down does not run
the script. The engine kills a process that runs past its deadline.
SUBS_SANDBOX_SLOTS sets how many scripts subs runs at once. The default is
the number of CPUs, from 2 to 8. A tenant may take every slot while no other
tenant waits; while another does, it keeps at most all but one.
Run scripts somewhere else
A program that embeds the engine chooses what runs scripts with
RuntimeConfig::script_runner, an implementation of
substructure_host::codemode::ScriptRunner. A runner takes one step of a
script, a Script, and returns where it ended, a Step. It keeps nothing
between steps, so it can be a process here, another machine, or a service. The
engine still decides how many steps run at once, reads each step from the
session, and checks every call a step asks for.
A runner that cannot take a step now, because it is down, busy or of another
version, returns a RunError. The engine puts the step back on its queue and
tries it again a few seconds later, until the step's queue timeout. A script
that fails is a Step::Failed, not an error.
The default runner, ProcessRunner, starts this binary as a confined process
for each step. That binary must call substructure_host::codemode::dispatch()
first in main, before it starts a thread. Without it, every script fails
with an error that says so.
The process speaks JSON lines on stdin and stdout. It first writes
{"ready": {"version": N, "oldest": M, "languages": [...]}}, or
{"unconfined": {"error": "..."}} when it could not confine itself. Then it
reads one Script and writes one Ran. While it runs, it may ask for file
bytes with {"read": {"file": "sales.csv", "offset": 0, "len": 262144}}, and
its runner answers {"bytes": n} followed by n raw bytes, or {"missing": "…"}. A runner reads a script's files from the ScriptFiles the engine passes it: each
file's blob and the store to read it from.
A runner on another machine can start the same binary with __sandbox the same
way, behind its own endpoint. Scripts that read no files need nothing more. For
files, the process that starts the binary answers its reads, so a runner on
another machine needs the blobs too: it reads them from the store, or the
engine answers the reads for it. The engine does not provide either yet. A
Script carries its version, and a runner takes every version from OLDEST to
VERSION, so an engine and its runners can be deployed one before the other.
Script steps wait on RuntimeDeps::script_task_queue.
Add a language
Nothing outside three places knows which language a script is in:
CodeLanguage, the enumcodemodetakes, insession::codemode.- A dialect in
session::codemode, which tells the model how to writerun_codein that language and declares each tool in its types.javascript.rsdeclares tools in TypeScript. - An engine in
host::codemode, an implementation ofEngine, listed inengine.rs. It interprets the language and asksReplayfor each call the script makes.Replayanswers from the script's records, holds it to its limits, and turns how it ended into a step, so an engine only binds the language: a call to a tool, printed output, the value returned, and an error raised.quickjs.rsis the JavaScript engine.
A runner process says which languages it runs when it starts, and a step in a language it does not run is tried again elsewhere.
Limits
A script has no network, file system, timer or module. The engine asks
RuntimeConfig::script_limits for a tenant's limits at each step, and a runner holds each script to
them. An error names the limit that was met and what to do. The 24 KiB of text a
run sends back is not among them: the model is told it, so the session sets it.
Files a script shows have their own budget, shown and shown_media.
| Limit | Default | Applies to |
|---|---|---|
calls | 1,000 | Calls over the whole script |
results | 8 MiB | Results the script reads, over all its calls |
arguments | 64 MiB | Arguments over the whole script |
call_arguments | 4 MiB | Arguments in one call |
step_arguments | 8 MiB | Arguments of the calls one step asks for, together |
memory | 256 MiB | JavaScript heap in one run |
compute_ms | 5,000 | CPU in one run |
total_compute_ms | 120,000 | CPU over all of a script's runs |
file_bytes | 64 MiB | File bytes one run reads |
total_file_bytes | 512 MiB | File bytes over all of a script's runs |
streams | 4 | Files open at once |
shown_files | 4 | Files the script shows the model |
shown | 64 KiB | File text the script shows the model |
shown_media | 2 MiB | Media files the script shows the model |
subs.toml sets them in a [codemode.limits] table. Sizes take bytes or a
word such as "4mb", and a limit left out keeps its default:
[codemode.limits]
call_arguments = "4mb"
memory = "512mb"
compute_ms = 5000The runner's process heap is twice memory, and at least 512 MiB.
A run reads the script, the results its calls already returned, and the files
it asks for, and stops at the first call with no result. Time spent waiting on
file reads does not count toward its deadline. So a run is quick, and a
run that needs more than five seconds of CPU is almost always a runaway. A
script resumes from the start after its calls finish, so work done before a
call counts against each run and against the whole budget. A run's process is
stopped three times its CPU allowance and one second after it starts, and the
operating system stops it a second past its allowance. RuntimeConfig::script_wait, 60 seconds by
default, is how long a step may wait for a slot or for a runner to take it.
ProcessRunner::with_data_limit caps a process's heap, records included; keep
it above memory. Parsed JSON takes about seven times its size in heap, so keep
results under an eighth of memory.
Each step logs a script step line under the codemode target, with its
tenant, the bytes of results it was given, its wait, its run time, its heap and
CPU, and the limit it met, if any. A step that uses over half its memory or
compute logs a warning.