DEV Community

Cover image for Everyone builds agents for frontier APIs. I built one for the models people actually run at home.
Fangyuan Lin
Fangyuan Lin

Posted on

Everyone builds agents for frontier APIs. I built one for the models people actually run at home.

Coding Showcase

TL;DR: Agent frameworks quietly assume an obedient model: clean JSON, instructions followed the first time, context to spare. Run a quantized 7B–35B in a 16K window and every one of those assumptions dies. I spent months building the application layer that keeps small local models productive anyway — all open source, MIT. Here's what actually breaks, and what fixed it.

The setting

Chaty is a fully offline desktop AI workspace — Rust / Tauri 2, llama.cpp plus native MLX on Apple Silicon. The coding agent inside it runs on whatever GGUF or MLX model you have: 7B, 8B, a 35B MoE. No cloud fallback, no retry budget. If the loop wastes rounds, you personally watch your GPU heat the room for nothing.

Three constraints shape everything below:

  • 16K context. Every tool doc line is a tax paid on every single turn.
  • Format adherence is probabilistic. The model usually emits valid tool calls. "Usually" is not an engineering contract.
  • Small models imitate whatever is in context — including their own mistakes. This one is load-bearing. Remember it; it explains half the design.

1. Parse what models actually emit

Here's a real tool call from a 35B MoE, captured from a raw dump:

{"name": "write_file", "path": "index.html", "content": "<!doctype html>...", "arguments": {}}
Enter fullscreen mode Exit fullscreen mode

Flat fields, plus a spurious empty arguments object — and the model frequently hits EOS right after the content string, before the closing brace. A strict parser rejects this, the loop asks again, and the model regenerates the entire HTML file. Ten rounds of that on one page.

The fixes, in order of importance:

  • Accept flat fields. And critically: an empty arguments: {} must not shadow them. Taking the {} at face value turned every such write into a missing-argument retry loop.
  • Repair missing closers — never mid-string. A string-aware scanner appends }/] only when every string is terminated. Auto-completing a truncated string would silently write a corrupted file, which is strictly worse than retrying.
  • Build parser fixtures from raw dumps of your target models, not from the spec. The spec is what models were trained toward; the dumps are what they do.

After this, the same page that burned ten rounds landed in one 76-second write.

2. A repeated correction is not a correction

A small model that sends search_code {} will send it again, no matter how politely you repeat the same error message. We ran the autopsy on one of these spirals: the old guard repeated an identical correction three times, then gave up and executed the empty call — which planted the model's own empty-arguments call into the transcript as an example to imitate. See the load-bearing constraint above.

The replacement is an escalating ladder, counted per tool:

  1. Slip 1: show a concrete valid example for this tool.
  2. Slip 2: stop suggesting the tool — divert: "try list_dir / read_file / grep instead." (We also raise sampling temperature here; a stuck attractor sometimes needs noise, not words.)
  3. Slip 3: the tool is declared disabled for this turn, and the stuck state gets a visible error card in the UI.
  4. Slip 5: pause the loop with a human-readable reason instead of spinning.

Empty calls are never executed, and a single valid call resets the counter. The principle: every rung must add new information. If two consecutive corrections are byte-identical, you don't have a correction — you have a chant.

3. Servers must not stall the loop

Models start dev servers in foreground shells. Naive harness behavior: block for 120 seconds, time out, and kill the very server the model was asked to start.

Detection is two-track, because one track lied to us:

  • Banner matching is the fast path — vite and friends print http://localhost:5173 instantly.
  • A listening socket is the ground truth. python3 -m http.server block-buffers its banner under a pipe; the text never arrives. So after a ~10s grace period we also ask the OS whether the command's process group holds a listening TCP socket (~every 3s).

Either signal → the process is moved to a tracked background job, not killed: output buffer carried over, job id returned to the model with "the server is up — navigate to it, check logs with bg_output."

One more zombie class: a shell that exits after server & used to leave the survivor squatting the port forever, invisible. Now the process group is checked on shell exit and survivors are adopted as [detached] background jobs — visible, killable, reaped on workspace switch and app exit.

4. Bring the console to the model

Agents love to "verify" a page by screenshotting the stale DOM. A screenshot proves the page looks like something; it proves nothing about behavior.

  • Interactions on your own pages auto-attach new console errors. Click, type, navigate on localhost or a local file → any new [error] / [exception] / [dialog] lines ride along in the tool result, deduplicated by a cursor, labeled honestly (a confirm dialog your click triggered is not an "error"). External websites stay noise-free — you don't want some ad network's console spam in a 16K context.
  • A real refresh verb. browser_refresh does a true hard reload (cache ignored). Before it existed, models said "refreshing the page" and then just took another screenshot of the old DOM.
  • A wrap-up gate. When the model tries to end the turn with unfinished todos, or with web files edited but never re-checked in the browser, it gets bounced — once. One corrective nudge, then its next answer stands. Zero nagging loops.

5. Loop breakers need judgment, not just a hammer

Intercepting identical repeated calls is table stakes — ls . five times helps nobody. But a blanket breaker kills legitimate work: clicking "Next" three times is pagination. The rule that survived our browser-bench autopsies: identical UI actions (click/type) may repeat while the page keeps changing; the moment an identical action produces an identical result — or follows an error — it's degenerate, and repeating a submit button in that state posts duplicates. Read-only observations (screenshot, re-read, poll) are exempt entirely.

6. The 16K token economy

Everything above competes with the user's actual code for context. The budget discipline:

  • One doc line per tool. Full schemas live in a deferred tier, loaded only when a tool is first needed.
  • Hints are just-in-time, not preloaded. The web-workflow lecture ("refresh after edits; screenshots prove looks, not behavior") is injected once, the first time a local URL appears — not carried by every session that never opens a browser.
  • Procedures and memory are files, not prompt. Skills (SKILL.md) cost one index line each until actually invoked; cross-session memory is markdown files with a hard-capped index. A user with none of these gets a byte-identical prompt — enforced by a golden test.

7. Treat tool output as hostile

Local doesn't mean safe: a web page your agent reads can carry <tool_call> markup or chat-template turn tokens and try to steer the loop. Every untrusted result (web content, MCP server output) gets control tokens defanged (a zero-width space makes them inert to the parser but readable to humans) and is framed as data-not-instructions. MCP tools ride the exact same defense as native web tools — checked against the live tool registry, since MCP tools register at runtime.

What didn't work (and taught the most)

  • Repeating the same correction. Three identical nudges did nothing; the fourth execution made it worse. Escalate or don't bother.
  • Fixing the harness instead of the product. Our bench once passed -u to Python servers so their banners would flush. That crutch hid the exact stall real users hit. If the harness needs a workaround, first ask whether the product has the bug.
  • Trusting banners. Text output is a courtesy, not a protocol. The OS knows whether something is listening; ask it.

Takeaway

None of this is model research. It's plumbing — parsing, process management, escalation policy, token budgeting. But on small local models, the application layer is where agents live or die: the same 8B that flails alone finishes real web tasks in a fraction of the steps with this layer under it.

All of it ships in Chaty — fully offline, Rust/Tauri 2, llama.cpp + native MLX, MIT. If you run a local model through it and something breaks, I want the raw transcript — that's where every fix above came from.

Top comments (0)