The Round-Trip Tax

Your agent’s most expensive habit is asking permission one tool call at a time.


The receipt

We ran 300 experiments across 14 tool configurations and 3 models. The task was identical: fix bugs in a Python codebase until all 48 tests pass. Same bugs, same tests, same success criteria. The only variable was which tools the agent could use and how.

Here are two configurations that both achieve 100% pass rates on the same task:

Condition A (structured tools)

Condition D (bash)

Pass rate

100% (n=5)

100% (n=5)

Cost

$1.43

$1.05

Turns

28.2

26.4

Tool calls

16.2

12.0

Content per call

~89 chars

~896 chars

D costs 27% less with 26% fewer tool calls. Both solve the same problem. The difference is structural: D packs 10x more content into each call. A single bash call that runs a Python one-liner reading three files does the work of three file_read calls — one turn instead of three.

The expensive part isn’t the tool call. It’s the round trip.


The tax

MCP’s protocol is one tool call per turn. The agent proposes a call. The Harness executes it. The result goes back into the conversation. The agent reads the result, the entire conversation history, the system prompt, the tool descriptions — and proposes the next call.

Every turn, the full context is re-sent to the model for inference. At turn 1, that’s a few thousand tokens. By turn 12, it’s 50,000. By turn 20, it’s pushing 100,000. The context grows monotonically because every tool result gets appended.

This isn’t unique to our experiments. Analysis of agent token usage on OpenRouter found that 99% of tokens consumed were input tokens — the trajectory re-reading itself — while only 1% were generated by the model . The round-trip tax isn’t the model’s reasoning cost. It’s the protocol’s structural overhead. And it compounds.

The agent in Condition A takes 28.2 turns to fix 13 bugs. Each turn re-processes the full conversation. A’s tool call pattern for a typical run looks like this:

file_list, file_list, file_list, run_tests, file_read, file_read,
file_read, file_read, file_edit, file_edit, file_edit, file_edit,
file_edit, run_tests, file_write

Fifteen calls. Twenty-eight turns (the model thinks between calls). Each turn re-reads everything that came before.

Bash escapes part of this tax by packing more work into each call. A Python one-liner that reads three files, extracts the relevant functions, and prints them is one turn instead of three. The model generates fewer output tokens because it expresses the same operations more compactly. Bash is a compression format for tool composition.

But bash is Level 4. The Harness can’t characterize what python -c "import ast; ..." will do without running it. We showed this in Closing the Channel — the computation channel’s type is honestly IO String, and narrowing it without lying requires building a structured replacement.

So we’re stuck: structured tools are expensive because of the round-trip tax, and bash is cheap but uncharacterizable.


The combinator idea

The theoretically correct answer is tool-call combinators. Instead of one call per turn, let the agent compose calls:

{
    "combinator": "gather",
    "calls": {
        "source": {"tool": "file_read", "args": {"path": "src/tokenizer.py"}},
        "tests": {"tool": "file_read", "args": {"path": "tests/test_tokenizer.py"}},
        "status": {"tool": "run_tests", "args": {}}
    }
}

Three operations, one turn. The Harness executes all three, returns all three results. The grade is the join of the component grades — if all three tools are Level 1, the composite is Level 1. The specified band holds because the combinator’s algebra is finite: gather, pipe, for_each, sequence, try_and_check. Each is a known operation over known tool calls.

The full combinator set compresses A’s 15-call workflow to roughly 4:

Before: list, list, list, run_tests, read, read, read, read, edit, edit,
         edit, edit, edit, run_tests, write
         = 15 tool calls, ~28 turns

After:  gather({files, tests}),
        try_and_check(do: edit_batch, check: run_tests),
        try_and_check(do: edit_batch, check: run_tests),
        write
        = 4 compound calls, ~8 turns

The cost prediction: 8 turns instead of 28 means roughly one-quarter the context re-processing. The structured tools stay characterizable. The ratchet’s promise fulfilled — the specified band expands to cover what bash did, without inheriting bash’s grade.

MCP BatchIt already enables parallel batching of independent tool calls, and it helps — but it can’t pass data between sub-operations. A gather that feeds results into a subsequent for_each requires data flow, not just parallelism. The composition problem is harder than the batching problem.

This is correct. It also doesn’t work.


The adoption problem

We tested batch tools. Condition J gave the agent only batch tools — file_edit_batch, file_read_batch, file_write. No individual file_edit. If you want to edit a file, you use the batch tool.

Condition J (batch-only)

Condition A (structured)

Pass rate

56%

78%

Cost

$1.55

$1.25

Turns

36.9

24.6

Output tokens

62,182

38,282

Batch-only was the worst configuration we tested. Highest cost, lowest pass rate, most turns, most output tokens. The agent struggled to construct the batch format, made errors in the JSON, retried, made different errors, and burned through its turn budget.

But maybe the problem was forcing batch-only. Condition H gave the agent both batch and individual tools, with a strategy instruction encouraging batch usage.

Condition H used zero batch calls. Not few — zero. file_edit_batch was available, the strategy instruction mentioned it, and the agent never called it. Not once across any run.

From the experimental findings: “file_edit_batch was available in H but never called. The agent needed the principle, not the tool.”

This is the adoption problem. JSON combinators are Harness-native, not agent-native. The agent was trained on code. It thinks in code. When you give it a JSON composition format, it either ignores it (Condition H) or struggles with it (Condition J). The affordance doesn’t match the training distribution.


What the training wants

Watch what agents actually do when given bash. They don’t write shell scripts. They write Python:

python -c "
import ast
with open('src/parser.py') as f:
    tree = ast.parse(f.read())
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        print(f'{node.name}:{node.lineno}')
"

That’s a program. It has variables, iteration, conditionals, function calls. The agent didn’t choose bash because it loves shells. It chose bash because bash is a gateway to writing code — and writing code is what language models are trained to do.

This is consistent with broader findings. Wang et al.’s CodeAct framework showed that agents using executable Python code for actions outperform tool-calling agents by up to 20% on benchmarks . HuggingFace’s smolagents library is built on the same observation: agents that “think in code” are more effective than agents that select from tool menus . The evidence is converging: code is the natural composition format for language models.

JSON combinators ask the agent to specify composition in a format it didn’t train on. Python asks the agent to specify composition in a format it trained on for millions of files. The content per call metric tells the story: 896 characters per bash call vs 89 characters per structured call. The agent is more fluent in code than in API calls.

This isn’t a tooling failure. It’s a training distribution mismatch. The solution isn’t better JSON — it’s meeting the model where it already is.


The question

What if we gave the agent a composition surface that was Python — actual Python, parsed with ast.parse(), executed with real semantics — but restricted?

Take Python. Remove import, def, class, while, try/except, lambda, eval, open. What’s left is: assignment, function calls, for-each over finite collections, conditionals on result properties, f-strings. A language for composing tool calls that happens to parse as Python.

The agent writes code. It thinks it’s programming. It is. But the computation channel is bounded. The Harness can walk the AST at parse time, verify that every function call resolves to a known tool, compute the composite grade as the join of tool grades, and reject anything outside the whitelist — all before execution.

This is the same move that SQL makes for data: a restricted computation language that looks like programming to the person writing it, but whose properties are decidable because the language isn’t Turing-complete. No unbounded loops. No function definitions. No imports. The agent gets the expressiveness of code with the grade of a structured query.

Felendler et al. arrived at a similar conclusion independently: their CE-MCP framework embeds code execution as a first-class MCP capability, consolidating multi-step workflows into single programs that execute within isolated runtimes . They found significant reductions in token usage and execution latency — but also identified sixteen attack classes across five execution phases. The security problem is real. Unrestricted code execution trades the round-trip tax for an expanded attack surface.

The question isn’t whether to let agents write code. They already do — through bash. The question is whether we can give them a code surface that’s restricted enough to stay in the specified band.

The round-trip tax drops because the agent composes N operations in one program instead of N separate turns. The specified band holds because the program’s AST is finite, walkable, and every node maps to a known operation. The adoption problem vanishes because the format matches the training.

That’s the theory. Next: what happens when we build it, and who — or what — writes the programs.


Next: The Lackpy Gambit — Take Python, subtract the dangerous parts. Then subtract the model.

See also

References