A command-line coding agent: an agentic loop with tool-calling that reads/writes files and runs shell commands until a task is done — in your own directory, asking before each change, or inside a Docker container. No agent framework — raw OpenAI-compatible API calls against Groq and a hand-rolled loop.
Benchmarked against Terminal-Bench.
pipx install dietcodeNeeds Python 3.11+. Docker is optional and only used by --sandbox.
pipx is recommended because it puts dietcode on your PATH and keeps its
dependencies isolated. pip install --user dietcode works too, but on Windows
you may then need to add %APPDATA%\Python\Python311\Scripts to PATH yourself.
dietcode doctor # checks Python, PATH, Docker and credentialsThen log in once:
dietcode login # pick a provider, paste a key (input is hidden)
dietcode auth # check what is configuredThe key goes into your OS keychain (Windows Credential Manager, macOS
Keychain, Secret Service on Linux), falling back to a 0600 file at
~/.dietcode/credentials.json. It is never written into the project.
| Provider | Free tier | Get a key |
|---|---|---|
groq |
yes, generous | console.groq.com/keys |
gemini |
yes | aistudio.google.com/apikey |
openai |
no | platform.openai.com |
Any OpenAI-compatible endpoint works via --base-url (Ollama, vLLM, OpenRouter).
Now run it from anywhere:
cd ~/some-project
dietcodeIt works in the directory you are standing in, and asks before anything that writes, deletes, or reaches outside it. Docker is not required.
Running from a checkout instead
git clone https://github.com/DevPatils/dietCode.git
cd dietCode
pip install -e ".[dev]"
python cli.py # same code as the installed commandA .env with GROQ_API_KEY=... also works when running from a checkout.
Troubleshooting — dietcode doctor diagnoses all of these:
| Symptom | Cause |
|---|---|
dietcode: command not found |
its Scripts/bin dir is not on PATH — use pipx |
no credentials for ... |
run dietcode login |
Docker errors with --sandbox |
Docker isn't running — drop the flag to work locally |
| files disappear after a run | you used --sandbox without --mount |
| What it does | Safety | |
|---|---|---|
dietcode |
Works in the current directory | Consent — asks before each change |
dietcode --sandbox |
Runs inside a Docker container | Containment — cannot reach anything you did not mount |
The default asks. Read-only commands (ls, cat, git status) run
without prompting; anything that writes, deletes, or reaches outside the
directory stops first with [y] yes [a] always allow [n] no. --yes skips
every prompt and is exactly as dangerous as it sounds.
The sandbox contains. --sandbox puts everything in a container capped at
2 GB memory, 2 CPUs and 512 PIDs. Add --mount ./dir to let files persist, or
--no-network to cut it off entirely. Either flag implies --sandbox.
The difference matters: a container is a boundary, a prompt is a decision. A
shell command can always cd .., so the default protects you by showing what
is about to happen, not by making escape impossible. For code you do not
trust, use --sandbox.
Run with no arguments for an interactive session in the current directory:
dietcodeOne session keeps its conversation and its files — the agent remembers
what it did on previous turns and the files it built are still there. Slash
commands: /help, /files, /cost, /sandbox, /clear (forget the
conversation, keep the files), /exit. Ctrl+C interrupts a turn without
quitting.
Or pass a task to run once and exit — used for scripting and the benchmark:
dietcode "add a test for the parser and make it pass"To run the same thing inside a container instead, add --sandbox. Note that a
container is thrown away when the run ends, so mount a directory if you want
the files to survive:
dietcode --sandbox --mount ./my-project "add a test for the parser"| Flag | Meaning |
|---|---|
--steps |
show step separators |
--no-stream |
wait for each reply instead of showing it as it is generated |
--subagents |
let the agent delegate self-contained work to sub-agents |
--no-context |
ignore the project's DIETCODE.md / AGENTS.md |
--provider groq|gemini|openai |
which API to use (default: your saved login) |
--base-url URL |
any OpenAI-compatible endpoint (Ollama, vLLM, OpenRouter) |
--no-network |
cut the sandbox off from the network entirely |
--max-tokens N |
hard spend ceiling per task |
--context-budget N |
trim the oldest turns above this prompt size (default 48000) |
--memory / --cpus / --pids-limit |
container resource caps (default 2g / 2 / 512) |
--cleanup |
remove every leftover agent container and exit |
--sandbox |
run inside a Docker container instead of the current directory |
--mount HOSTDIR[:TARGET] |
bind-mount a directory into the container. Implies --sandbox |
--yes |
approve every action without asking (dangerous) |
--container NAME |
attach to an existing container instead of creating one |
--image IMAGE |
sandbox image (default python:3.11-slim) |
--model NAME |
default llama-3.3-70b-versatile |
--max-iterations N |
default 12 |
--json |
print metrics as JSON |
--quiet |
only print the final result |
Exit code is 0 when the agent called task_complete, 1 otherwise.
python -m pytest # Docker tests skip if the daemon is down
python -m pytest tests/test_loop.py # one file
python -m pytest -k timeout # one testThe loop tests use a scripted fake client (tests/fake_llm.py), so the suite
needs no API key and makes no network calls.
interactive ─┐
one-shot ──┼─> agent_loop ──> execute_tool ──> Executor ──> container
tb run ──┘ (agent/loop.py) (agent/tools.py) (agent/sandbox.py)
All three entrypoints run the same loop. Interactive mode differs only in that
it passes the previous turn's messages back in as history and reuses one
container; rendering lives in agent/ui.py so the loop stays UI-free and the
benchmark can run it with no console attached.
Replies stream token by token in both human-facing modes. agent_loop(stream=…)
defaults to off, and the benchmark leaves it off deliberately: streaming
means reassembling tool calls from fragments, which is strictly more machinery
to go wrong, and a scored run gains nothing from output nobody watches. Both
transports normalize to the same Completion, so the loop itself is identical
either way.
agent_loop calls the model, executes whatever tools it asks for, feeds the
results back, and repeats until task_complete, a turn with no tool calls, or
max_iterations.
Tools: read_file, write_file, edit_file, find_files, search,
run_shell, task_complete — plus spawn_subagent behind --subagents.
edit_file replaces an exact snippet rather than rewriting the file, so a
one-line change costs one line instead of four hundred. It refuses rather than
guesses: no match, or an ambiguous match, is an error explaining what to fix.
--subagents lets the agent delegate self-contained work to a fresh agent that
shares the files but not the conversation, and reports back only a summary.
The context isolation is the point — passing the transcript back would cost as
much as doing the work inline.
Project instructions. If the working directory has a DIETCODE.md,
AGENTS.md, CLAUDE.md or .cursorrules, it is appended to the system prompt
and takes precedence over the defaults. Read from the host, so the agent can't
rewrite its own standing orders. --no-context skips it.
The only thing that differs between the CLI and the benchmark is which Executor
gets passed in, so both run identical tool code.
execute_toolnever raises. Llama and Qwen emit malformed tool-call JSON, invented tool names and wrong-typed arguments often enough that treating those as exceptions would kill a run several times per benchmark. Every failure comes back as an error string the model can read and correct.- Tool calls written as prose are recovered. On the very first real run,
llama-3.3-70b emitted
<function/run_shell {...}</function>as message text rather than through the tool-calling API. The loop saw no tool calls and stopped on step 1 with the task untouched.extract_tool_calls_from_textparses the known text formats, and the recovered call is rewritten into the transcript in correct structural form. Counted separately asrecovered_tool_calls— it measures the model, not the scaffold. - File tools go through the executor, not the host filesystem. Otherwise the benchmark agent would read the host while its shell acts in the container.
task_completebatched with the work gets deferred. Models often emit write + run +task_completein a single turn, declaring the output verified before a single tool result existed. One run wrote bash into a.pyfile, got aSyntaxError, and claimed success in the same breath — it would have scored a false pass. The loop now feeds the results back and requirestask_completeon its own turn.- Schemas stay permissive where the dispatcher coerces. Groq validates tool
arguments server-side and 400s the whole generation on a mismatch; a model
sending
"timeout": "10"killed a run. The rejected text comes back infailed_generation, so the call is salvaged from it rather than lost. - The shell wrapper persists the working directory between calls. Each
docker execis a fresh process, socd /appin one command would be silently lost by the next. - Written file content is base64'd over argv, so nothing the model generates
can be reinterpreted as shell syntax. Costs a ~1MB write ceiling (
ARG_MAX).
The agent runs shell commands an LLM wrote, so containers are capped by default:
2 GB memory, 2 CPUs, 512 PIDs, plus no-new-privileges. The PID cap is what
stops a fork bomb from wedging the Docker VM rather than just failing a command.
Networking is on by default (the agent often needs pip install). Use
--no-network for untrusted work — note that combined with --mount, a
networked agent can read your mounted files and send them somewhere.
Every container is labelled, and startup sweeps ones older than 6 hours left
behind by a crash. --cleanup removes them all now. This matters because
close() only runs on a clean exit — SIGKILL leaks a container otherwise.
Long sessions trim their own history: above --context-budget tokens the oldest
turns are dropped, always keeping tool calls and their results together (splitting
a pair makes the API reject the whole request). Trimming applies to what is
sent; the full transcript is still recorded.
The harness does not run on Windows. Use WSL or Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh # uv brings its own Python 3.13
uv tool install terminal-bench
wsl bash scripts/benchmark.sh # hello-world
wsl bash scripts/benchmark.sh broken-python # a single task
DATASET=terminal-bench-core==0.1.1 wsl bash scripts/benchmark.sh "" # everythingDocker Desktop's WSL integration means WSL shares the same daemon — no second install. The agent itself is fine on Windows; only the harness is not.
Four terminal-bench 0.2.18 problems this works around
- Its dataset downloader shells out to Unix
rm -rf .git. Needs Git'susr/binon PATH, or just run it on Linux. terminal-bench-core@headpoints at./tasks, but the repo moved toharbor-framework/terminal-benchand renamed that directoryoriginal-tasks/. Pin==0.1.1(commit91e10457b5).- Windows blocker: container paths are built with
pathlib.Path, so/tmpbecomes\tmpand the run dies inTmuxSession.__init__with404 Could not find the file \tmp— before the agent is ever called. The0.00%this produces is not a score; checktotal_input_tokens: nullinresults.jsonto tell "harness failed" from "agent failed". - It finishes by printing
output_path.absolute(), which callsos.getcwd(). On a OneDrive-backed folder over WSL's drvfs that can throw after a successful run. Passing an absolute--output-pathavoids the call.
tb does not read .env; the adapter loads it itself, and the script exports
the key as well in case the harness's isolated environment lacks python-dotenv.
Use a fixed ~15–20 task subset while iterating — not the full suite, and not repeatedly. Groq's free tier is ~1,000 requests/day and each task burns one request per loop step.
Per-task metrics.json and transcript.json are written into the harness's
logging directory; they are the input to the failure-mode table below.
Smoke test only so far — one task, which is not a score.
| Task | Result | Steps | Tokens | Notes |
|---|---|---|---|---|
hello-world |
✅ resolved | 3 | 1,806 | 1 tool call recovered from text |
The full subset run is the next step. The table below stays empty until then rather than extrapolating from a single task.
| Resolution rate | Avg steps | Avg tokens | |
|---|---|---|---|
| This agent | — | — | — |
| Terminus (reference) | — | — | — |
Both defensive mechanisms earned their place immediately. From the transcript:
- Step 1's tool call arrived as prose, not through the tool-calling API. The
recovered call is visible in the log as a synthesized id (
call_1_0) with empty content. Withoutextract_tool_calls_from_textthe loop would have stopped at step 1,hello.txtwould never have been written, and the task would have failed. - Step 2 sent
"timeout": "30"as a string. That is exactly the payload that previously drew a 400 and killed a run; the permissive schema absorbed it.
One task on one model is a smoke test, so treat recovered_tool_calls as the
interesting number here, not the pass.
Built and working end to end: tool dispatch, agent loop, Docker sandbox, CLI, Terminal-Bench adapter.
First real run, "write a python script that prints the first 20 primes and run it"
on llama-3.3-70b-versatile — completed in 4 steps / 4658 tokens:
| Step | What happened |
|---|---|
| 1 | Tool call arrived as text; recovered and run. The command itself was malformed (literal \n inside python -c "...") → SyntaxError |
| 2 | Model read the error, switched to write_file, and used a proper structured tool call |
| 3 | Ran the script; correct output |
| 4 | task_complete |
Not yet done:
- A benchmark run.
- Stretch goal:
spawn_subagent— a fresh loop with isolated message history that returns only its final summary to the parent.agent_looptakes anextra_tool_handlershook for exactly this, and the hook is tested; the tool itself is deliberately left until there is a baseline score to compare against.