A local 27B coding agent on the 780M: why it froze, and what unstuck it

Background

DeepSeek Harness (dsh) is an open-source coding-agent CLI/web app, the same category as Claude Code, wired here against a local Ollama model instead of a paid API. The model is Qwen3.8-27B-UD-Q3_K_XL, an Unsloth dynamic quant, 13.15GB, running fully on the 780M’s shared VRAM partition (ollama ps confirms 100% GPU) at roughly 4.2 tokens/second for generation and 40-60 tokens/second for prompt ingestion. The task: build a single-file Phaser 3 tower defense game and see how far a local model this slow gets as an unattended agent.

The freeze

First attempt: one prompt, the full spec (tower placement, enemy waves, combat, win/lose states, a mandatory self-verification pass), sent as a normal single turn. It never wrote a single line of code.

Output token limit reached
Input 8.7K tok · Output 24.1K tok

24,100 tokens of pure Think block, no tool call, cut off by the response ceiling. The ceiling itself turned out not to be a separate setting. Reading dsh-llm-pi-ai’s source, a model’s maxTokens field is optional and falls back to whatever’s left of the context window once the prompt is subtracted. 32,768 (the configured num_ctx) minus 8,700 input lands at 24,068, close enough to the reported 24.1K to call it confirmed. It ran out of room mid-thought, having never once decided the thinking was done.

Splitting the work by hand fixes it

Seven separate prompts instead of one, each describing only the current slice (boilerplate, then path/HUD, then tower placement, then enemies, then combat, then waves, then a final grep-based verification pass), each roughly 20-60 minutes at 4.2 tok/s. Every step produced clean, correct code, usually with real self-correction along the way: a stray splice left over from a botched edit, caught and fixed on its own; a hover-collision question about method naming reasoned through correctly; a node --check syntax pass run through a shell tool before declaring a step done, unprompted.

A real permission bug turned up along the way. The harness enforces a strict sandbox-widening rule (a session can only escalate to a mode strictly wider than its current one), and starting a session in workspace-write while the model habitually re-requests workspace-write after any denial trips that rule the moment ambient permissions and the requested permissions match, since a request for the same level it already has never counts as “wider.” The fix matches the model’s own behavior instead of working around it: it only asks for workspace-write reactively, after an actual write fails, never before. Starting a session already in workspace-write means no denial ever happens, so the escalation logic never triggers. Every subsequent step ran with zero permission prompts.

Telling it to plan first, in the same prompt, doesn’t help

The obvious next question: was the freeze about scope, or about the original prompt never telling it to work incrementally? Same full spec, one prompt, this time with an explicit instruction to write a short numbered plan, execute only the first (tiny) increment, and stop.

It still failed, differently. Over an hour, 13,000-plus tokens, one unbroken Think block, zero tool calls, the model second-guessing exact lineStyle() color values for a path it hadn’t decided to draw yet. Seeing the whole feature list up front seems to trigger this on its own, independent of whether a scoping instruction is present. A second, unrelated bug surfaced here too: dsh-llm-pi-ai defaults streamIdleTimeoutMs to 300,000 (five minutes), a threshold tuned for a hosted API’s response times, several times faster than this 4.2 tok/s local model. When it fires mid-generation, the whole response restarts from scratch, discarding whatever the model had worked out. Raised to 1,800,000 in settings.yaml.

Plan mode produces a plan and then can’t act on it

The harness ships a native /plan mode, modeled on the same idea as Claude Code’s. In it, the model produced something solid: a numbered increment list with concrete acceptance checks per step, explicit forward-thinking about later code (“define constants top-of-file, so later projectile code won’t need a config change”), a self-imposed length cap it respected without prompting. Then, told to also write real code for increment one, it stalled indefinitely trying to reconcile that instruction with a mode that disables file writes by design, since plan mode plans without executing, and asking it to do both in the same breath is a contradiction the model has no clean way to resolve.

The fix: the model’s own goal tool

dsh exposes create_goal directly to the model (found by reading dsh-tool-goal’s source), a tool the model can call on its own initiative for anything it judges to be a long-running objective. Once created, the harness drives automatic continuation across rounds up to a configured cap, with ordinary read/write permissions intact throughout, unlike plan mode.

Same full spec, same “keep round one trivially small” framing, this time pointed at create_goal with a 10-round cap, in normal mode. create_goal fired almost immediately, followed by a Glob check that the target file didn’t already exist, followed by a correct, minimal first write, a blank Phaser scene with a dark background, inside roughly ten minutes. The turn ended with a self-generated summary of what round one shipped and what round two would add next, and round two started on its own, no further message sent.

Result

Four attempts to unstick the same freeze landed at four different outcomes. Manual task-splitting worked, at the cost of a human doing the splitting every time. An inline plan-then-act instruction failed outright, with the model still seeing the whole mountain it was building toward regardless of the instruction. Native plan mode produced better planning than either, then hit a wall the mode itself put there. create_goal let the model decide on its own that the task needed breaking up, broke it up competently, and kept acting round after round with no human doing the chunking by hand. Single-turn reasoning never changed across any of it, still the same weights with the same tendency to keep drafting a color value nobody asked for yet. What moved each time was the turn-boundary decision, when the model stops reasoning and commits to an action. On hardware running at 4.2 tokens a second, that one decision is what stands between a shipped feature and a monologue about one that never got written.