Repository navigation
Your Agents Need One Tool and It's Not Bash #1
umutsevdi
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Your Agents Need One Tool and It's Not Bash
Article is also available here
Like many coding agents, Imza originally exposed purpose-built tools for reading files, searching code, editing source, and running commands.
There was a reason for using structured tools instead of giving the model an unrestricted shell. Every tool call had a known effect. Imza could evaluate permissions before execution, record mutations, render diffs, and keep an explicit trace of what the agent had done.
But as I kept developing Imza, I noticed the model repeatedly composing shell commands.
Sessions usually started with the structured tools provided to the agent, but over time more operations moved into pipelines and shell scripts.
This was a problem for several reasons. Shell script mutations are hard to track. And even if I provided all tools to the agents, they still preferred shell scripts to those tools, most likely due to the training data.
I wanted to put my original findings to the test, and analyzed my previous sessions in Imza. Observations come from the first 50 tool-using sessions in the Imza session store.
Why Shell Calls Existed
Imza was already using Tree-sitter to analyze shell commands for safety, so examining how those commands were being used was straightforward.
head/tail)grep -E 'error')57% of all shell usage existed purely to manage context. The model was already practicing code-mode economics, but through an uncontrolled channel: no diffs, coarse program-level grants, best-effort parsing of compound strings.
I wanted to preserve the programmability while moving critical operations back across interfaces Imza could understand.
The Drift in Sessions
Assume the beginning of a session is 0 and the end is 1. Mean position of each tool's calls within a session:
Some of that late shell usage is legitimate. Build, test, and verification phases naturally require commands.
Shell dominance also partly reflects increasing context pressure as sessions grow.
The target was never to make shell usage disappear. The target was to stop filtering, batching, and mutation composition from living exclusively inside a black box.
The Read Tool Was Already Telling the Same Story
Imza's
readtool supported explicit line ranges so the model could avoidloading entire files. 842/1312 reads used explicit line ranges.
This means models were already capable of getting rid of unnecessary data.
They just needed better tools to operate.
The Solution
I replaced the growing collection of independent tools with a programmable orchestration layer.
For that layer I chose Lua because it is lightweight, embeddable, and simple to constrain inside a VM. Lua itself does not own the capabilities.
Permission evaluation, diffs, and the transcript were unchanged mechanisms.
Existing tool calls were repurposed as binding methods in the Lua VM.
Standard Lua methods that could mutate the state were removed.
The migration happened gradually.
First, the
luatool shipped alongside the existing tools. Capabilities were then moved into bindings family by family: read-only operations, session operations, mutations, and finally shell execution.The native tool roster was removed only after every capability had a binding counterpart with the same permission semantics.
Architecture
The VM itself (
src/tools/lua.cpp) is configured for containment: a custom allocator caps the VM at 256 MB,printoutput is hard-capped at 64 KiB, and onlybase,table,string,math, andcoroutineexists inside the sandbox. A fresh VM is created per tool call.Two tools were kept outside of the
lua.skill: loads a Markdown file directly into agent context. There is little value in composing it programmatically.subagent: blocks the main flow while child agents execute, so exposing it as an operation inside a script provides little benefit.The original tools supported several overloads, which occasionally caused models to invoke them incorrectly. The new bindings have distinct names, arguments, and responsibilities, while remaining callable any number of times within one script.
Every mutation binding captures the affected file's before and after state at the capability boundary.
If one script modifies the same file repeatedly, Imza collapses those mutations into a single net before/after diff and attaches it to the tool result for inline rendering.
Enshittifying The Bash
During the initial Lua rollout, models still occasionally drifted back toward compound shell commands.
Although this happened less frequently than in the before, it still needed to be fixed.
So I made bash deliberately worse than it was.
The shell binding is parsed with Tree-sitter and rejects expressions containing multiple commands.
Composition belongs in Lua, and after this change, shell usage dropped drastically.
Compatibility
Saved sessions from older versions are still supported. The renderer uses the fallback viewer to display legacy tools. The Fallback viewer treats every tool the same so some cosmetic breakage is expected.
Results
22 lua-heavy sessions were recorded and analyzed after the migration, against 54 native-heavy sessions recorded before it
The session store tracks token usage in memory only and does not save it to the session records. So the primary endpoint was estimated with a proxy: tool-result characters carried in history. That is the dominant input-token term, because every tool result is re-fed to the model on each subsequent turn.
Lua scripts also require more argument tokens than simple native calls.
However, oversized results compound across all remaining turns, and batching reduces turn count, which the per-turn figure does not capture.
Estimate: roughly 25–40% fewer tool-context tokens per unit of work, with total session token savings in the 20–35% band once argument and turn-count effects are included.
What Did Not Improve
Failure Handling
169 lua calls ended in
REJECTand 89 in errors.A rejected permission aborts the whole script, whereas a rejected native tool call affected only that operation. The orchestration made the process cheap, but at the cost of mid-script recovery.
The Drift Did Not Vanish
Shell mean position is
0.56via binding vs0.57native; late-session shell takeover persists (28 of 59 large sessions). However, this was expected, since most coding tasks naturally begin with inspection, continue through mutation, and finish by invoking compilers, test runners, formatters, or other utilities.The goal was never to eliminate shell usage. It was to tame it.
Reproducibility
Every number in this article comes from the session store on disk.
The complete analysis can be reproduced with:
The script normalizes both execution models into one operation stream.
Native tool calls from pre-migration sessions and binding calls from the Lua dispatch log collapse into the same vocabulary. Metrics can therefore be calculated together, instead of comparing two unrelated representations.
The tool-result character counts used above are calculated directly from persisted session JSON.
I Am Not the First
The technique is not novel; I am not its inventor. Thanks to insights from the following projects, companies, developers, and communities:
makiruns acode_executiontool over the same pattern.What's Different in Imza
Imza's implementation goes all in: it removes regular tool calls almost entirely and forces models to orchestrate their actions through a monitored programming layer.
The model gets one composable interface, while permissions, mutations, diffs, and execution traces remain visible to the host.
References
code_executiontoolAll reactions