# Building Codey, a Coding Agent Harness in Go — Build Plan Target: a harness that can be pointed at a real Go or TypeScript repo, told "make the failing test pass," and actually do it — with numbers proving how often it works. ## Assumptions - Single provider: Anthropic Messages API. Multi-provider is a later refactor, not a starting constraint. - Go 1.22+. Generics are used for the tool registry. - Terminal interface. No TUI framework at first — plain stdout, add Bubble Tea only if you want it later. - Solo project, roughly 5–6 weeks part-time. **Stopping condition: 7 out of 10 eval tasks passing, or six weeks, whichever comes first.** (See METRICS.md.) ## Repo layout ``` codey/ cmd/codey/main.go # flag parsing, wiring, REPL entry internal/ llm/ client.go # HTTP + SSE against the Messages API types.go # Message, ContentBlock, ToolUse, ToolResult stream.go # partial tool-call JSON accumulation agent/ loop.go # the core turn loop state.go # conversation history, file-read tracking compact.go # summarization when the window fills tools/ registry.go # Tool interface + typed wrapper read.go glob.go grep.go edit.go write.go bash.go truncate.go # shared output-capping helper approval/ policy.go # allow/deny/ask, with session memory metrics/ log.go # JSONL event log — every tool call, every token count ui/ render.go diff.go evals/ tasks/ # one dir per task: setup.sh, prompt.txt, verify.sh runner.go METRICS.md # you fill this in as you go. This is the deliverable. ``` Two rules that save pain later: 1. **`agent` never imports `ui`.** The loop must be runnable headless, because the eval runner drives it. 2. **Every tool returns `(string, error)` where the error is for the model to read**, not for you to log and swallow. A failed edit is a normal event in the transcript, not an exception. ## Phase 0 — Skeleton and the tool registry Time: half a day. **Done when:** `go test ./...` passes with a fake tool registered and dispatched. The one piece of Go design worth getting right on day one is typed tool arguments, because otherwise you'll be doing `map[string]any` assertions in every tool for the rest of the project. Derive `schemaFor[T]()` from struct tags with `github.com/invopop/jsonschema` rather than hand-writing JSON Schema. Hand-written schemas drift from the structs and you won't notice until the model sends a field you don't parse. Also in this phase: `metrics.Log(event)` writing one JSON object per line to `./.codey/events.jsonl`. Wire it before you have anything to measure. Retrofitting instrumentation is how projects end up with no numbers. ## Phase 1 — The minimal loop Time: 1–2 days. **Done when:** you ask "what does main.go do?" and the agent reads the file and answers. Non-streaming first. Streaming is a distraction until the loop is correct. ``` build messages → POST /v1/messages → response if stop_reason == "tool_use": for each tool_use block: dispatch, collect result append assistant message + user message containing tool_result blocks repeat else: print text, return ``` Things that bite here: - Tool results go back as a **user** message containing `tool_result` blocks, one per `tool_use` id, in a **single** message. Multiple parallel tool calls means multiple result blocks in that one message — not one message each. - Every `tool_use` id must get a result, even if execution failed. Send the error text with `is_error: true`. A missing result is an API error. - Set a max-turn cap (start at 30) and return a clear message when hit. Unbounded loops burn money fast. - Thread `context.Context` from main through the loop into every tool from the very start. Retrofitting cancellation later means touching every signature. Only one tool this phase: `read_file(path, offset?, limit?)`. ## Phase 2 — The real tool set Time: 1 week. **Done when:** the agent can find code it hasn't been shown, change it, and run the tests. ### `edit_file(path, old_str, new_str)` — the centerpiece - Reject if the file hasn't been read this session. Track `map[string]time.Time` of reads; if the file's mtime changed since, force a re-read. This single rule kills a large class of hallucinated edits. - Count exact occurrences of `old_str`. - **1 match** → apply. Return a short unified diff of the changed region, not "OK". The model uses the diff to verify it did what it meant. - **0 matches** → do not just say "not found." Retry the match with normalized whitespace; if that hits, tell the model the indentation differs and show the actual line. If it still misses, return the 20 lines around the closest-matching line. A failure that carries context gets fixed in one turn; a bare "not found" costs three. - **>1 match** → return the line numbers of every match and ask for a longer unique span. Never silently pick the first. - Preserve the file's existing line endings. Detect on read, restore on write. - Log every attempt to the metrics file with the outcome. Edit success rate is your headline metric. ### `bash(command, timeout_ms)` - Run via `exec.CommandContext` so cancellation works. - Cap output. Use head+tail (e.g. first 100 and last 100 lines with a `… N lines elided …` marker), never head alone — test failures live at the end. - Default timeout 60s; kill the process **group**, not just the process, or orphaned children hang the loop. - Return exit code, stdout and stderr together, clearly labelled. ### `glob(pattern)` and `grep(pattern, path?, glob?)` Shell out to `rg` if present, fall back to `filepath.WalkDir` + `regexp`. Cap results at ~100 with a count of how many were suppressed. This is your entire code-search story; you do not need embeddings. ### `write_file(path, content)` New files only, or full rewrites the model explicitly chose. Keep it separate from edit so your metrics don't blend them. **Hard rule from this phase onward:** run Codey only against a throwaway git clone, or inside a container. There is no approval layer until Phase 6. Assume it will run `rm -rf` on something eventually. ## Phase 3 — Evals. Do not skip this, do not defer it Time: 3–4 days. **Done when:** `go run ./evals -n 3` prints a pass rate and a metrics table. This is the pivot point of the whole project. Everything after this gets measured; everything before it was guesswork. Task format — one directory each: ``` evals/tasks/fix-nil-deref/ setup.sh # clone/copy a repo into a temp dir, apply a breaking patch prompt.txt # what the user would type verify.sh # exit 0 == task solved ``` Ten tasks, drawn from real repos you know. A good spread: 1. Fix a failing unit test (obvious cause) 2. Fix a failing unit test (cause is two files away from the failure) 3. Add a small feature with a test 4. Rename a symbol across 5+ files 5. Add error handling to an existing function 6. Fix a compile error from a partial refactor 7. Write a test for existing untested code 8. Find and fix a bug from a description only, no stack trace 9. Update a dependency call site after an API change 10. A task that *should* fail — verify it says so instead of claiming success Runner requirements: - Fresh temp dir per run, parallel across tasks, `-n 3` to repeat each (agents are nondeterministic; a single run tells you nothing). - Persist the full transcript per run to `evals/runs///.json`. You will read these constantly. Most regressions are obvious within ten seconds of reading a trajectory. - Print: pass rate, mean turns, mean input/output tokens, mean wall time, edit success rate, tool call counts by name. Record the baseline in METRICS.md that day. Every later phase adds a row. ## Phase 4 — Context management, measured Time: 4–5 days. **Done when:** a task requiring 40+ turns completes, and your eval numbers didn't drop. - Per-tool output caps (from Phase 2) — tune the numbers against evals rather than intuition. - **Compaction:** at ~70% of the window, summarize the older half of the conversation into a single message. Preserve: the original task, files touched and why, decisions made, things discovered the hard way ("the config is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not 95% — compaction itself needs headroom. - **The 97% floor:** the 70% rule keeps the last six messages, so it declines a history too short to cut six off — a few enormous tool results that have nevertheless filled the window. That restraint is right until the window is nearly full, at which point a verbatim tail buys nothing if the next request cannot be sent. Past `-compact-floor` (0.97), compact anyway with a one-message tail — the smallest that is still a valid conversation; if even that finds nothing to fold, say so rather than let the next request fail as an opaque API error. This is a backstop, not a second opinion about when to compact: 70% is still where it normally happens. - **`/compact`:** the same fold on demand, for starting a long task with a clean slate. It waits for the current turn, since it rewrites the history that turn is building on, and past the floor it takes the smaller tail too — with auto-compaction off it is the only move left. - **Turning it off:** `auto_compact: "off"` (or `-no-auto-compact`) for anyone who would rather lose the turn than the transcript. The floor then stops the turn instead of folding, refusing the prompt before it joins the history so that compacting and retrying does not send it twice. `/compact` is unaffected: the setting is about the automatic fold, not the manual one. - **Stale reads:** if a file was read 20 turns ago and edited since, its old contents in history are actively harmful. Simplest fix that works: replace superseded `read_file` results with a stub pointing at the current version. - **Prompt caching:** keep the prefix byte-stable — system prompt, then tool definitions, then project context, then the growing conversation. No timestamps, no session ids, no "files changed" block near the front. Add cache breakpoints and check `cache_read_input_tokens` in responses to confirm hits. This is your single biggest cost and latency lever, and compaction breaks it, which is another argument for compacting rarely. Run evals after each change. Expect at least one of these to make things worse. ## Phase 5 — Streaming, interruption, and the interface Time: 3–4 days. **Done when:** you can hit Ctrl-C mid-`go test` and give a correction without losing the session. - Switch `llm.Client` to SSE. The fiddly part is accumulating `input_json_delta` fragments per tool-call index into complete JSON before dispatch — buffer per block index, parse on `content_block_stop`. - Ctrl-C cancels the current turn's context. The partially-run tool returns an error result, and control comes back to you for a follow-up message. Second Ctrl-C within a second or two exits. - Show diffs before applying, with color. - Show running token and cost totals per session. Cheap to add, and it changes how you design. - Persist sessions to `.codey/sessions/.json` and add `--resume`. Keep `agent` free of terminal dependencies. The eval runner depends on that. ## Phase 6 — Safety Time: 2–3 days. **Done when:** you'd run it on your actual working repo without flinching. - **Approval policy:** read-only tools auto-approve. `bash`, `write_file`, `edit_file` prompt, with "yes / no / always for this exact command / always for this tool" and session memory. - **Do not allowlist shell commands with string matching.** `git status; rm -rf ~` defeats it, and so do backticks, `$()`, `&&`, `xargs`, and any interpreter with `-c`. Either parse with `mvdan.cc/sh/syntax` and check each command node, or don't pretend and rely on the sandbox plus prompts. - **Path jail:** resolve every file path with `filepath.EvalSymlinks` and verify it's inside the working root. Cheap and catches a lot. - **Sandbox:** the honest answer for a Go project on Linux is a container with the repo bind-mounted and network off by default. Decide explicitly about egress — that's the line between "broke my repo" and "read my .env and made an HTTP request." ## Phase 7 — Optional, pick at most one Only after you've hit your eval target. - Sub-agents: spawn a fresh context for a search task, return only the summary. Big context win, real complexity cost. - Multi-provider (OpenAI, local models). Note that this means maintaining per-model prompt variants, not one abstraction. - A CLAUDE.md-style project context file loaded into the cached prefix. - Bubble Tea TUI. ## Handing phases to Claude Code Give it one phase at a time, with the interfaces fixed. A prompt shape that works: > Implement Phase 2 of the plan in PLAN.md: the `edit_file` tool in > `internal/tools/edit.go`. It must satisfy the `tools.Tool` interface already > defined in `registry.go` — don't change that interface. Requirements: reject > edits to files not read this session (use the tracker in `agent/state.go`); > exact-match `old_str`; on zero matches retry with normalized whitespace and > return the 20 surrounding lines of the closest candidate; on multiple matches > return every match's line number and refuse; on success return a unified diff > of the changed region; preserve the file's original line endings. Write > table-driven tests covering all four match outcomes plus CRLF preservation. > Run `go test ./internal/tools/...` and iterate until green. What makes this work: one file, an interface it may not change, explicit failure-mode requirements, and a command it can run to check itself. Vague phase-level prompts ("build the tool system") produce plausible code that fails in ways you won't find for a week. ## What you're actually producing The code is the byproduct. The deliverable is METRICS.md.