All posts

One script instead of ten tool calls

run_js chains tool calls inside a single JavaScript run, so the middle of the work stays out of the conversation and only the answer comes back.

“Find where the cache key is built, check the three callers, and tell me which one passes the wrong argument.” That request needs a search, three reads and a comparison. Done as separate tool calls it also needs five round trips, and every file lands in the conversation on the way.

run_js takes a different route. You give it one JavaScript body, the body calls tools, and only the answer returns.

Six calls, six turns

A direct tool call is a round trip. The agent asks for a tool, the result comes back into the conversation, the agent reads it and decides what to do next. That works well for one lookup. It gets expensive when the second call depends on the first and the third depends on the second, because each hop adds a turn and each result adds text that stays in context for the rest of the chat.

Most of that text is scaffolding. You wanted which caller passes the wrong argument. You did not want three full files.

Write the loop instead of describing it

A run_js call carries two fields: an intention and one async function body. Inside the body, callTool is the only tool interface.

const hits = await callTool("grep", { pattern: "cacheKey", path: "src" });
const files = [...new Set(hits.text.split("\n").filter(Boolean))].slice(0, 3);

const reads = await Promise.all(files.map((file) => callTool("read", { path: file })));
const wrong = reads.filter((read) => !read.text.includes("cacheKey("));

return { checked: files, suspects: wrong.map((read) => read.text.split("\n")[0]) };

The shape of the work lives in the code. await handles a call that needs the previous result. Promise.all runs independent calls at the same time. Loops and array methods reduce a batch before anything leaves the script.

Each call resolves to { text, error }. text is always a string, so a script never crashes on an empty result, and error is null on success. A failed call is data: the script can skip it, record it, or return a partial answer. Only a JavaScript exception or a timeout fails the whole run, and then the code can be corrected and sent again.

What comes back

The script runs in an isolated sandbox on your machine with the tool set for that turn, including any tool the agent activated just before. The return value is the whole message. A string goes back as written. Anything else is serialized as JSON.

Two caps keep a script from flooding the chat:

  • One nested tool call can return 48 KB to the script.
  • The value the script returns is capped at 16,000 characters.

A run that returns more than 16,000 characters gets a note telling the agent to return something smaller. The fix is usually a tighter projection: a count instead of a list, the first line instead of the file, the failing entry instead of the log.

A run has 30 seconds by default. If the script calls bash, the timeout stretches to 30 minutes, because build and test commands need the room.

Big output already has a place to go

Some results are genuinely large: a 900-line build log, a search across a whole project, a diff on a generated file. Tarsk stores a tool result above 2 KB and puts a bounded preview in the conversation with an obs_ id beside it. The preview holds at most 2 KB and 40 lines.

The agent pages the rest with get_observation. It can ask for a later window by line offset, up to 80 lines and 50 KB per call, and it can batch several ids in one call. The stored body is immutable, so a later page returns the same bytes even if the file on disk has changed since. That matters when you ask about a failure that has already been fixed.

Compression that refuses to grow

Search results get grouped before they reach the model. Matches collapse under their directory, and a shared path prefix is written once as a header instead of on every line. A directory listing that reads src/app/chat/a.ts, src/app/chat/b.ts becomes src/app/: chat/a.ts, chat/b.ts.

The grouping only ships when it is smaller. Tarsk compares the compressed body plus its header against the flat list, and sends the flat list when the header would cost more than it saves. Nothing is lost either way: the header plus the grouping reconstructs every original line.

The bigger version we built and removed

We tried to take this further. Every intermediate result got a durable handle, and a later step could page any part of it without running the tool again. Results were deduplicated across searches and ranked against the query. The design worked, and the numbers did not.

We measured it on six fixed large cases and two small controls, with tokens counted by a real tokenizer rather than a character estimate:

Approach Tokens Bytes
Direct tool calls 64,782 24,173
Handles, schema discovered per run 51,985 26,275
Handles, full catalog loaded up front 68,521 10,827

The handle version cut tokens by 19.8% in one policy and bytes by 55.2% in another. It never cut both at once. The gate was 30% on each, so the benchmark failed while every correctness check passed, and we removed the handle machinery rather than keep two ways to orchestrate a run.

Small results also got worse. A two-byte read returned 2,444 bytes of handle metadata under the heavier policy, against 2 bytes with a direct call. Handle metadata paid for itself on a large log and cost more than the answer on a small one, and most answers are small.

When a direct call wins

run_js is for a chain, a batch or a projection. For one known file window, a direct read is shorter and clearer.

Some tools stay out of the script on purpose. ask_user, tool_search, the browser tool and MCP connection each need their own moment in the interface, and a nested call cannot produce it. The agent tool is excluded as well, because nesting one agent inside another makes the run hard to follow and hard to stop.

Try it

Next time a request needs several lookups, ask for the answer rather than the evidence: “check the three callers and tell me which one is wrong”. The agent can put that work in one script and hand back a short result.

  • run_js runs one JavaScript body, and callTool is the only interface inside it.
  • Large results are stored with a bounded preview, and get_observation pages them back on request.
  • Compression ships only when it is smaller than the plain output.

Open a chat and ask a question that needs three files. Watch how much of the answer is the answer.