Vectrum
Sign In

Phase 0: skeleton, typed tool registry, metrics log

Tool interface with a generic typed[T] wrapper so tools never touch
map[string]any, and input schemas derived from struct tags via
invopop/jsonschema rather than hand-written JSON.

Registry.Execute is the single dispatch point, which makes it the single
place tool calls get logged. List() sorts by name to keep the tool
definitions byte-stable in the cached prompt prefix.

metrics is a *Logger rather than a package-level Log: the eval runner in
Phase 3 needs one file per parallel run. A nil *Logger is a no-op so call
sites need no guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted commit0642294

Showing 10 changed files with 1045 additions and 0 deletions.

+3−0.gitignoreaddedView File
@@ -0,0 +1,3 @@
1+ /.harness/
2+ /evals/runs/
3+ /harness
+13−0METRICS.mdaddedView File
@@ -0,0 +1,13 @@
1+ # METRICS
2+
3+ The deliverable. One row per change that could plausibly move the numbers.
4+ Numbers come from `go run ./evals -n 3` (Phase 3); anything hand-waved gets `—`.
5+
6+ Stopping condition: **7/10 eval tasks passing, or 2026-10-28, whichever comes first.**
7+
8+ | Change | Pass rate | Edit success | Mean turns | Mean cost |
9+ | --- | --- | --- | --- | --- |
10+ | Phase 0 skeleton | — | — | — | — |
11+
12+ Baseline gets recorded the day the eval runner first prints a table. Until then
13+ this file is a promise, not data.
+291−0PLAN.mdaddedView File
@@ -0,0 +1,291 @@
1+ # Building a Coding Agent Harness in Go — Build Plan
2+
3+ Target: a harness that can be pointed at a real Go or TypeScript repo, told
4+ "make the failing test pass," and actually do it — with numbers proving how
5+ often it works.
6+
7+ ## Assumptions
8+
9+ - Single provider: Anthropic Messages API. Multi-provider is a later refactor,
10+ not a starting constraint.
11+ - Go 1.22+. Generics are used for the tool registry.
12+ - Terminal interface. No TUI framework at first — plain stdout, add Bubble Tea
13+ only if you want it later.
14+ - Solo project, roughly 5–6 weeks part-time.
15+
16+ **Stopping condition: 7 out of 10 eval tasks passing, or six weeks, whichever
17+ comes first.** (See METRICS.md.)
18+
19+ ## Repo layout
20+
21+ ```
22+ harness/
23+ cmd/harness/main.go # flag parsing, wiring, REPL entry
24+ internal/
25+ llm/
26+ client.go # HTTP + SSE against the Messages API
27+ types.go # Message, ContentBlock, ToolUse, ToolResult
28+ stream.go # partial tool-call JSON accumulation
29+ agent/
30+ loop.go # the core turn loop
31+ state.go # conversation history, file-read tracking
32+ compact.go # summarization when the window fills
33+ tools/
34+ registry.go # Tool interface + typed wrapper
35+ read.go glob.go grep.go
36+ edit.go write.go
37+ bash.go
38+ truncate.go # shared output-capping helper
39+ approval/
40+ policy.go # allow/deny/ask, with session memory
41+ metrics/
42+ log.go # JSONL event log — every tool call, every token count
43+ ui/
44+ render.go diff.go
45+ evals/
46+ tasks/ # one dir per task: setup.sh, prompt.txt, verify.sh
47+ runner.go
48+ METRICS.md # you fill this in as you go. This is the deliverable.
49+ ```
50+
51+ Two rules that save pain later:
52+
53+ 1. **`agent` never imports `ui`.** The loop must be runnable headless, because
54+ the eval runner drives it.
55+ 2. **Every tool returns `(string, error)` where the error is for the model to
56+ read**, not for you to log and swallow. A failed edit is a normal event in
57+ the transcript, not an exception.
58+
59+ ## Phase 0 — Skeleton and the tool registry
60+
61+ Time: half a day. **Done when:** `go test ./...` passes with a fake tool
62+ registered and dispatched.
63+
64+ The one piece of Go design worth getting right on day one is typed tool
65+ arguments, because otherwise you'll be doing `map[string]any` assertions in
66+ every tool for the rest of the project.
67+
68+ Derive `schemaFor[T]()` from struct tags with `github.com/invopop/jsonschema`
69+ rather than hand-writing JSON Schema. Hand-written schemas drift from the
70+ structs and you won't notice until the model sends a field you don't parse.
71+
72+ Also in this phase: `metrics.Log(event)` writing one JSON object per line to
73+ `./.harness/events.jsonl`. Wire it before you have anything to measure.
74+ Retrofitting instrumentation is how projects end up with no numbers.
75+
76+ ## Phase 1 — The minimal loop
77+
78+ Time: 1–2 days. **Done when:** you ask "what does main.go do?" and the agent
79+ reads the file and answers.
80+
81+ Non-streaming first. Streaming is a distraction until the loop is correct.
82+
83+ ```
84+ build messages → POST /v1/messages → response
85+ if stop_reason == "tool_use":
86+ for each tool_use block: dispatch, collect result
87+ append assistant message + user message containing tool_result blocks
88+ repeat
89+ else: print text, return
90+ ```
91+
92+ Things that bite here:
93+
94+ - Tool results go back as a **user** message containing `tool_result` blocks,
95+ one per `tool_use` id, in a **single** message. Multiple parallel tool calls
96+ means multiple result blocks in that one message — not one message each.
97+ - Every `tool_use` id must get a result, even if execution failed. Send the
98+ error text with `is_error: true`. A missing result is an API error.
99+ - Set a max-turn cap (start at 30) and return a clear message when hit.
100+ Unbounded loops burn money fast.
101+ - Thread `context.Context` from main through the loop into every tool from the
102+ very start. Retrofitting cancellation later means touching every signature.
103+
104+ Only one tool this phase: `read_file(path, offset?, limit?)`.
105+
106+ ## Phase 2 — The real tool set
107+
108+ Time: 1 week. **Done when:** the agent can find code it hasn't been shown,
109+ change it, and run the tests.
110+
111+ ### `edit_file(path, old_str, new_str)` — the centerpiece
112+
113+ - Reject if the file hasn't been read this session. Track
114+ `map[string]time.Time` of reads; if the file's mtime changed since, force a
115+ re-read. This single rule kills a large class of hallucinated edits.
116+ - Count exact occurrences of `old_str`.
117+ - **1 match** → apply. Return a short unified diff of the changed region, not
118+ "OK". The model uses the diff to verify it did what it meant.
119+ - **0 matches** → do not just say "not found." Retry the match with
120+ normalized whitespace; if that hits, tell the model the indentation differs
121+ and show the actual line. If it still misses, return the 20 lines around
122+ the closest-matching line. A failure that carries context gets fixed in one
123+ turn; a bare "not found" costs three.
124+ - **>1 match** → return the line numbers of every match and ask for a longer
125+ unique span. Never silently pick the first.
126+ - Preserve the file's existing line endings. Detect on read, restore on write.
127+ - Log every attempt to the metrics file with the outcome. Edit success rate is
128+ your headline metric.
129+
130+ ### `bash(command, timeout_ms)`
131+
132+ - Run via `exec.CommandContext` so cancellation works.
133+ - Cap output. Use head+tail (e.g. first 100 and last 100 lines with a
134+ `… N lines elided …` marker), never head alone — test failures live at the end.
135+ - Default timeout 60s; kill the process **group**, not just the process, or
136+ orphaned children hang the loop.
137+ - Return exit code, stdout and stderr together, clearly labelled.
138+
139+ ### `glob(pattern)` and `grep(pattern, path?, glob?)`
140+
141+ Shell out to `rg` if present, fall back to `filepath.WalkDir` + `regexp`. Cap
142+ results at ~100 with a count of how many were suppressed. This is your entire
143+ code-search story; you do not need embeddings.
144+
145+ ### `write_file(path, content)`
146+
147+ New files only, or full rewrites the model explicitly chose. Keep it separate
148+ from edit so your metrics don't blend them.
149+
150+ **Hard rule from this phase onward:** run the harness only against a throwaway
151+ git clone, or inside a container. There is no approval layer until Phase 6.
152+ Assume it will run `rm -rf` on something eventually.
153+
154+ ## Phase 3 — Evals. Do not skip this, do not defer it
155+
156+ Time: 3–4 days. **Done when:** `go run ./evals -n 3` prints a pass rate and a
157+ metrics table.
158+
159+ This is the pivot point of the whole project. Everything after this gets
160+ measured; everything before it was guesswork.
161+
162+ Task format — one directory each:
163+
164+ ```
165+ evals/tasks/fix-nil-deref/
166+ setup.sh # clone/copy a repo into a temp dir, apply a breaking patch
167+ prompt.txt # what the user would type
168+ verify.sh # exit 0 == task solved
169+ ```
170+
171+ Ten tasks, drawn from real repos you know. A good spread:
172+
173+ 1. Fix a failing unit test (obvious cause)
174+ 2. Fix a failing unit test (cause is two files away from the failure)
175+ 3. Add a small feature with a test
176+ 4. Rename a symbol across 5+ files
177+ 5. Add error handling to an existing function
178+ 6. Fix a compile error from a partial refactor
179+ 7. Write a test for existing untested code
180+ 8. Find and fix a bug from a description only, no stack trace
181+ 9. Update a dependency call site after an API change
182+ 10. A task that *should* fail — verify it says so instead of claiming success
183+
184+ Runner requirements:
185+
186+ - Fresh temp dir per run, parallel across tasks, `-n 3` to repeat each (agents
187+ are nondeterministic; a single run tells you nothing).
188+ - Persist the full transcript per run to `evals/runs/<ts>/<task>/<n>.json`. You
189+ will read these constantly. Most regressions are obvious within ten seconds
190+ of reading a trajectory.
191+ - Print: pass rate, mean turns, mean input/output tokens, mean wall time, edit
192+ success rate, tool call counts by name.
193+
194+ Record the baseline in METRICS.md that day. Every later phase adds a row.
195+
196+ ## Phase 4 — Context management, measured
197+
198+ Time: 4–5 days. **Done when:** a task requiring 40+ turns completes, and your
199+ eval numbers didn't drop.
200+
201+ - Per-tool output caps (from Phase 2) — tune the numbers against evals rather
202+ than intuition.
203+ - **Compaction:** at ~70% of the window, summarize the older half of the
204+ conversation into a single message. Preserve: the original task, files
205+ touched and why, decisions made, things discovered the hard way ("the config
206+ is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not
207+ 95% — compaction itself needs headroom.
208+ - **Stale reads:** if a file was read 20 turns ago and edited since, its old
209+ contents in history are actively harmful. Simplest fix that works: replace
210+ superseded `read_file` results with a stub pointing at the current version.
211+ - **Prompt caching:** keep the prefix byte-stable — system prompt, then tool
212+ definitions, then project context, then the growing conversation. No
213+ timestamps, no session ids, no "files changed" block near the front. Add
214+ cache breakpoints and check `cache_read_input_tokens` in responses to confirm
215+ hits. This is your single biggest cost and latency lever, and compaction
216+ breaks it, which is another argument for compacting rarely.
217+
218+ Run evals after each change. Expect at least one of these to make things worse.
219+
220+ ## Phase 5 — Streaming, interruption, and the interface
221+
222+ Time: 3–4 days. **Done when:** you can hit Ctrl-C mid-`go test` and give a
223+ correction without losing the session.
224+
225+ - Switch `llm.Client` to SSE. The fiddly part is accumulating
226+ `input_json_delta` fragments per tool-call index into complete JSON before
227+ dispatch — buffer per block index, parse on `content_block_stop`.
228+ - Ctrl-C cancels the current turn's context. The partially-run tool returns an
229+ error result, and control comes back to you for a follow-up message. Second
230+ Ctrl-C within a second or two exits.
231+ - Show diffs before applying, with color.
232+ - Show running token and cost totals per session. Cheap to add, and it changes
233+ how you design.
234+ - Persist sessions to `.harness/sessions/<id>.json` and add `--resume`.
235+
236+ Keep `agent` free of terminal dependencies. The eval runner depends on that.
237+
238+ ## Phase 6 — Safety
239+
240+ Time: 2–3 days. **Done when:** you'd run it on your actual working repo without
241+ flinching.
242+
243+ - **Approval policy:** read-only tools auto-approve. `bash`, `write_file`,
244+ `edit_file` prompt, with "yes / no / always for this exact command / always
245+ for this tool" and session memory.
246+ - **Do not allowlist shell commands with string matching.** `git status; rm -rf ~`
247+ defeats it, and so do backticks, `$()`, `&&`, `xargs`, and any interpreter
248+ with `-c`. Either parse with `mvdan.cc/sh/syntax` and check each command
249+ node, or don't pretend and rely on the sandbox plus prompts.
250+ - **Path jail:** resolve every file path with `filepath.EvalSymlinks` and
251+ verify it's inside the working root. Cheap and catches a lot.
252+ - **Sandbox:** the honest answer for a Go project on Linux is a container with
253+ the repo bind-mounted and network off by default. Decide explicitly about
254+ egress — that's the line between "broke my repo" and "read my .env and made
255+ an HTTP request."
256+
257+ ## Phase 7 — Optional, pick at most one
258+
259+ Only after you've hit your eval target.
260+
261+ - Sub-agents: spawn a fresh context for a search task, return only the summary.
262+ Big context win, real complexity cost.
263+ - Multi-provider (OpenAI, local models). Note that this means maintaining
264+ per-model prompt variants, not one abstraction.
265+ - A CLAUDE.md-style project context file loaded into the cached prefix.
266+ - Bubble Tea TUI.
267+
268+ ## Handing phases to Claude Code
269+
270+ Give it one phase at a time, with the interfaces fixed. A prompt shape that
271+ works:
272+
273+ > Implement Phase 2 of the plan in PLAN.md: the `edit_file` tool in
274+ > `internal/tools/edit.go`. It must satisfy the `tools.Tool` interface already
275+ > defined in `registry.go` — don't change that interface. Requirements: reject
276+ > edits to files not read this session (use the tracker in `agent/state.go`);
277+ > exact-match `old_str`; on zero matches retry with normalized whitespace and
278+ > return the 20 surrounding lines of the closest candidate; on multiple matches
279+ > return every match's line number and refuse; on success return a unified diff
280+ > of the changed region; preserve the file's original line endings. Write
281+ > table-driven tests covering all four match outcomes plus CRLF preservation.
282+ > Run `go test ./internal/tools/...` and iterate until green.
283+
284+ What makes this work: one file, an interface it may not change, explicit
285+ failure-mode requirements, and a command it can run to check itself. Vague
286+ phase-level prompts ("build the tool system") produce plausible code that fails
287+ in ways you won't find for a week.
288+
289+ ## What you're actually producing
290+
291+ The code is the byproduct. The deliverable is METRICS.md.
+53−0cmd/harness/main.goaddedView File
@@ -0,0 +1,53 @@
1+ // Command harness is the CLI entry point: flag parsing, wiring, and (from
2+ // Phase 1) the REPL.
3+ package main
4+
5+ import (
6+ "encoding/json"
7+ "flag"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "time"
12+
13+ "harness/internal/metrics"
14+ "harness/internal/tools"
15+ )
16+
17+ func main() {
18+ if err := run(); err != nil {
19+ fmt.Fprintln(os.Stderr, "harness:", err)
20+ os.Exit(1)
21+ }
22+ }
23+
24+ func run() error {
25+ workdir := flag.String("C", ".", "working root the agent operates in")
26+ logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path")
27+ flag.Parse()
28+
29+ root, err := filepath.Abs(*workdir)
30+ if err != nil {
31+ return err
32+ }
33+
34+ session := time.Now().UTC().Format("20060102T150405Z")
35+ log, err := metrics.Open(filepath.Join(root, *logPath), session)
36+ if err != nil {
37+ return err
38+ }
39+ defer log.Close()
40+ log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{"root": root}})
41+
42+ reg := tools.NewRegistry(log)
43+ // Tools land here in Phase 1 (read_file) and Phase 2 (the rest).
44+
45+ fmt.Printf("harness session %s\nroot: %s\nmetrics: %s\ntools: %d registered\n",
46+ session, root, filepath.Join(root, *logPath), len(reg.List()))
47+ for _, t := range reg.List() {
48+ schema, _ := json.Marshal(json.RawMessage(t.InputSchema()))
49+ fmt.Printf(" %-12s %s\n %s\n", t.Name(), t.Description(), schema)
50+ }
51+ fmt.Println("\nno agent loop yet — Phase 1.")
52+ return nil
53+ }
+12−0go.modaddedView File
@@ -0,0 +1,12 @@
1+ module harness
2+
3+ go 1.26.8
4+
5+ require github.com/invopop/jsonschema v0.14.0
6+
7+ require (
8+ github.com/bahlo/generic-list-go v0.2.0 // indirect
9+ github.com/buger/jsonparser v1.1.2 // indirect
10+ github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
11+ go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
12+ )
+18−0go.sumaddedView File
@@ -0,0 +1,18 @@
1+ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+ github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
4+ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7+ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
8+ github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
9+ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
10+ github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
11+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
12+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
13+ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
14+ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
15+ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
16+ go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
17+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
18+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+154−0internal/metrics/log.goaddedView File
@@ -0,0 +1,154 @@
1+ // Package metrics writes a JSONL event log: one JSON object per line, one line
2+ // per thing worth counting later. It is wired in before there is anything to
3+ // measure on purpose — retrofitting instrumentation is how projects end up with
4+ // no numbers.
5+ package metrics
6+
7+ import (
8+ "encoding/json"
9+ "fmt"
10+ "io"
11+ "os"
12+ "path/filepath"
13+ "sync"
14+ "time"
15+ )
16+
17+ // DefaultPath is where the harness logs unless told otherwise.
18+ const DefaultPath = ".harness/events.jsonl"
19+
20+ // Event is one line of the log. Extra is flattened into the same JSON object so
21+ // the file stays greppable with jq without nested lookups.
22+ type Event struct {
23+ Time time.Time `json:"ts"`
24+ Session string `json:"session,omitempty"`
25+ Type string `json:"type"`
26+
27+ // Tool dispatch.
28+ Tool string `json:"tool,omitempty"`
29+ Duration int64 `json:"dur_ms,omitempty"`
30+ OK *bool `json:"ok,omitempty"`
31+ Err string `json:"err,omitempty"`
32+
33+ // Turn accounting.
34+ Turn int `json:"turn,omitempty"`
35+ StopReason string `json:"stop_reason,omitempty"`
36+
37+ // Token accounting, mirroring the Messages API usage block.
38+ InputTokens int `json:"input_tokens,omitempty"`
39+ OutputTokens int `json:"output_tokens,omitempty"`
40+ CacheReadTokens int `json:"cache_read_input_tokens,omitempty"`
41+ CacheWriteTokens int `json:"cache_creation_input_tokens,omitempty"`
42+
43+ Extra map[string]any `json:"-"`
44+ }
45+
46+ // MarshalJSON flattens Extra alongside the declared fields. Keys in Extra never
47+ // clobber a declared field.
48+ func (e Event) MarshalJSON() ([]byte, error) {
49+ type alias Event
50+ b, err := json.Marshal(alias(e))
51+ if err != nil {
52+ return nil, err
53+ }
54+ if len(e.Extra) == 0 {
55+ return b, nil
56+ }
57+ var m map[string]json.RawMessage
58+ if err := json.Unmarshal(b, &m); err != nil {
59+ return nil, err
60+ }
61+ for k, v := range e.Extra {
62+ if _, taken := m[k]; taken {
63+ continue
64+ }
65+ ev, err := json.Marshal(v)
66+ if err != nil {
67+ return nil, fmt.Errorf("metrics: extra field %q: %w", k, err)
68+ }
69+ m[k] = ev
70+ }
71+ return json.Marshal(m)
72+ }
73+
74+ // Logger appends events to a writer. The zero value and a nil *Logger are both
75+ // usable and simply drop events, so callers never need a nil check.
76+ type Logger struct {
77+ mu sync.Mutex
78+ w io.Writer
79+ closer io.Closer
80+ session string
81+ }
82+
83+ // Open appends to path, creating parent directories as needed.
84+ func Open(path, session string) (*Logger, error) {
85+ if dir := filepath.Dir(path); dir != "." {
86+ if err := os.MkdirAll(dir, 0o755); err != nil {
87+ return nil, fmt.Errorf("metrics: %w", err)
88+ }
89+ }
90+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
91+ if err != nil {
92+ return nil, fmt.Errorf("metrics: %w", err)
93+ }
94+ return &Logger{w: f, closer: f, session: session}, nil
95+ }
96+
97+ // NewWriter logs to an arbitrary writer. Useful in tests and in the eval runner,
98+ // which keeps a separate log per run.
99+ func NewWriter(w io.Writer, session string) *Logger {
100+ return &Logger{w: w, session: session}
101+ }
102+
103+ // Session returns the session id stamped onto every event.
104+ func (l *Logger) Session() string {
105+ if l == nil {
106+ return ""
107+ }
108+ return l.session
109+ }
110+
111+ // Log writes one event. Errors are dropped: a broken metrics file must never
112+ // take down a run.
113+ func (l *Logger) Log(ev Event) {
114+ if l == nil || l.w == nil {
115+ return
116+ }
117+ if ev.Time.IsZero() {
118+ ev.Time = time.Now().UTC()
119+ }
120+ if ev.Session == "" {
121+ ev.Session = l.session
122+ }
123+ b, err := json.Marshal(ev)
124+ if err != nil {
125+ return
126+ }
127+ l.mu.Lock()
128+ defer l.mu.Unlock()
129+ l.w.Write(append(b, '\n'))
130+ }
131+
132+ // ToolCall records one dispatch. err nil means success.
133+ func (l *Logger) ToolCall(name string, d time.Duration, err error, extra map[string]any) {
134+ ok := err == nil
135+ ev := Event{
136+ Type: "tool_call",
137+ Tool: name,
138+ Duration: d.Milliseconds(),
139+ OK: &ok,
140+ Extra: extra,
141+ }
142+ if err != nil {
143+ ev.Err = err.Error()
144+ }
145+ l.Log(ev)
146+ }
147+
148+ // Close closes the underlying file, if there is one.
149+ func (l *Logger) Close() error {
150+ if l == nil || l.closer == nil {
151+ return nil
152+ }
153+ return l.closer.Close()
154+ }
+148−0internal/metrics/log_test.goaddedView File
@@ -0,0 +1,148 @@
1+ package metrics
2+
3+ import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+ )
14+
15+ func decode(t *testing.T, line string) map[string]any {
16+ t.Helper()
17+ var m map[string]any
18+ if err := json.Unmarshal([]byte(line), &m); err != nil {
19+ t.Fatalf("line is not valid JSON (%v): %s", err, line)
20+ }
21+ return m
22+ }
23+
24+ func TestLogWritesOneObjectPerLine(t *testing.T) {
25+ var buf bytes.Buffer
26+ l := NewWriter(&buf, "sess-1")
27+ l.Log(Event{Type: "turn", Turn: 1, InputTokens: 900})
28+ l.Log(Event{Type: "turn", Turn: 2, InputTokens: 1200})
29+
30+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
31+ if len(lines) != 2 {
32+ t.Fatalf("got %d lines, want 2:\n%s", len(lines), buf.String())
33+ }
34+ first := decode(t, lines[0])
35+ if first["session"] != "sess-1" {
36+ t.Errorf("session = %v, want sess-1 stamped automatically", first["session"])
37+ }
38+ if first["ts"] == nil {
39+ t.Error("ts must be filled in when the caller leaves it zero")
40+ }
41+ if first["input_tokens"].(float64) != 900 {
42+ t.Errorf("input_tokens = %v, want 900", first["input_tokens"])
43+ }
44+ }
45+
46+ func TestExtraIsFlattened(t *testing.T) {
47+ var buf bytes.Buffer
48+ NewWriter(&buf, "s").Log(Event{
49+ Type: "edit",
50+ Tool: "edit_file",
51+ Extra: map[string]any{"matches": 3, "path": "main.go"},
52+ })
53+ m := decode(t, buf.String())
54+ if m["matches"].(float64) != 3 || m["path"] != "main.go" {
55+ t.Errorf("Extra should sit alongside declared fields, got %v", m)
56+ }
57+ }
58+
59+ func TestExtraCannotClobberDeclaredFields(t *testing.T) {
60+ var buf bytes.Buffer
61+ NewWriter(&buf, "s").Log(Event{Type: "edit", Extra: map[string]any{"type": "spoofed"}})
62+ if m := decode(t, buf.String()); m["type"] != "edit" {
63+ t.Errorf("type = %v, want the declared value to win", m["type"])
64+ }
65+ }
66+
67+ func TestToolCallRecordsOutcome(t *testing.T) {
68+ tests := []struct {
69+ name string
70+ err error
71+ wantOK bool
72+ wantEr string
73+ }{
74+ {name: "success", err: nil, wantOK: true},
75+ {name: "failure", err: errors.New("no such file"), wantOK: false, wantEr: "no such file"},
76+ }
77+ for _, tc := range tests {
78+ t.Run(tc.name, func(t *testing.T) {
79+ var buf bytes.Buffer
80+ NewWriter(&buf, "s").ToolCall("read_file", 12*time.Millisecond, tc.err, nil)
81+ m := decode(t, buf.String())
82+ if m["type"] != "tool_call" || m["tool"] != "read_file" {
83+ t.Fatalf("unexpected event %v", m)
84+ }
85+ if m["ok"] != tc.wantOK {
86+ t.Errorf("ok = %v, want %v", m["ok"], tc.wantOK)
87+ }
88+ if m["dur_ms"].(float64) != 12 {
89+ t.Errorf("dur_ms = %v, want 12", m["dur_ms"])
90+ }
91+ if tc.wantEr != "" && m["err"] != tc.wantEr {
92+ t.Errorf("err = %v, want %q", m["err"], tc.wantEr)
93+ }
94+ })
95+ }
96+ }
97+
98+ func TestNilLoggerIsANoOp(t *testing.T) {
99+ var l *Logger
100+ l.Log(Event{Type: "turn"})
101+ l.ToolCall("x", 0, nil, nil)
102+ if l.Session() != "" {
103+ t.Error("nil logger should report an empty session")
104+ }
105+ if err := l.Close(); err != nil {
106+ t.Errorf("Close on nil logger = %v, want nil", err)
107+ }
108+ }
109+
110+ func TestOpenCreatesParentDirsAndAppends(t *testing.T) {
111+ path := filepath.Join(t.TempDir(), ".harness", "events.jsonl")
112+ for i := 0; i < 2; i++ {
113+ l, err := Open(path, "s")
114+ if err != nil {
115+ t.Fatalf("Open: %v", err)
116+ }
117+ l.Log(Event{Type: "run_start"})
118+ l.Close()
119+ }
120+ b, err := os.ReadFile(path)
121+ if err != nil {
122+ t.Fatalf("ReadFile: %v", err)
123+ }
124+ if n := strings.Count(string(b), "\n"); n != 2 {
125+ t.Errorf("got %d lines, want 2 (second Open must append, not truncate)", n)
126+ }
127+ }
128+
129+ func TestConcurrentLogLinesStayIntact(t *testing.T) {
130+ var buf bytes.Buffer
131+ l := NewWriter(&buf, "s")
132+ var wg sync.WaitGroup
133+ for i := 0; i < 50; i++ {
134+ wg.Add(1)
135+ go func(i int) {
136+ defer wg.Done()
137+ l.Log(Event{Type: "tool_call", Turn: i})
138+ }(i)
139+ }
140+ wg.Wait()
141+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
142+ if len(lines) != 50 {
143+ t.Fatalf("got %d lines, want 50", len(lines))
144+ }
145+ for _, ln := range lines {
146+ decode(t, ln)
147+ }
148+ }
+158−0internal/tools/registry.goaddedView File
@@ -0,0 +1,158 @@
1+ // Package tools defines the tool interface the agent loop dispatches against.
2+ //
3+ // Tools return (string, error). The error is text for the model to read, not an
4+ // exception for the harness to swallow: a failed edit is a normal event in the
5+ // transcript.
6+ package tools
7+
8+ import (
9+ "context"
10+ "encoding/json"
11+ "fmt"
12+ "sort"
13+ "sync"
14+ "time"
15+
16+ "harness/internal/metrics"
17+
18+ "github.com/invopop/jsonschema"
19+ )
20+
21+ // Tool is everything the loop needs to advertise and run one tool.
22+ type Tool interface {
23+ Name() string
24+ Description() string
25+ InputSchema() json.RawMessage
26+ Execute(ctx context.Context, raw json.RawMessage) (string, error)
27+ }
28+
29+ // typed adapts a func taking a concrete argument struct to the Tool interface,
30+ // so individual tools never touch map[string]any.
31+ type typed[T any] struct {
32+ name, desc string
33+ schema json.RawMessage
34+ fn func(context.Context, T) (string, error)
35+ }
36+
37+ func (t typed[T]) Name() string { return t.name }
38+ func (t typed[T]) Description() string { return t.desc }
39+ func (t typed[T]) InputSchema() json.RawMessage { return t.schema }
40+
41+ func (t typed[T]) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
42+ if len(raw) == 0 {
43+ raw = json.RawMessage("{}")
44+ }
45+ var in T
46+ if err := json.Unmarshal(raw, &in); err != nil {
47+ return "", fmt.Errorf("invalid arguments: %w", err)
48+ }
49+ return t.fn(ctx, in)
50+ }
51+
52+ // New builds a Tool whose input schema is derived from T's struct tags. It
53+ // panics if T cannot be reflected, which is a programming error at wiring time.
54+ func New[T any](name, desc string, fn func(context.Context, T) (string, error)) Tool {
55+ return typed[T]{name: name, desc: desc, schema: SchemaFor[T](), fn: fn}
56+ }
57+
58+ // reflector inlines everything: the Messages API wants a self-contained schema
59+ // object, not one stitched together from $defs.
60+ var reflector = &jsonschema.Reflector{
61+ ExpandedStruct: true,
62+ DoNotReference: true,
63+ }
64+
65+ // SchemaFor derives a JSON Schema from T. Fields tagged omitempty are optional;
66+ // everything else is required. Descriptions come from `jsonschema:"description=..."`.
67+ func SchemaFor[T any]() json.RawMessage {
68+ var zero T
69+ b, err := json.Marshal(reflector.Reflect(zero))
70+ if err != nil {
71+ panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
72+ }
73+ var m map[string]any
74+ if err := json.Unmarshal(b, &m); err != nil {
75+ panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
76+ }
77+ // Metadata the API has no use for, and which would only add bytes to the
78+ // cached prompt prefix.
79+ delete(m, "$schema")
80+ delete(m, "$id")
81+ delete(m, "version")
82+ if _, ok := m["type"]; !ok {
83+ m["type"] = "object"
84+ }
85+ if _, ok := m["properties"]; !ok {
86+ m["properties"] = map[string]any{}
87+ }
88+ out, err := json.Marshal(m)
89+ if err != nil {
90+ panic(fmt.Sprintf("tools: cannot reflect schema for %T: %v", zero, err))
91+ }
92+ return out
93+ }
94+
95+ // Registry holds the tools available to one session and is the single place
96+ // tool dispatch gets logged, so no individual tool has to remember to do it.
97+ type Registry struct {
98+ mu sync.RWMutex
99+ byName map[string]Tool
100+ order []string
101+ log *metrics.Logger
102+ }
103+
104+ // NewRegistry returns an empty registry. log may be nil.
105+ func NewRegistry(log *metrics.Logger) *Registry {
106+ return &Registry{byName: map[string]Tool{}, log: log}
107+ }
108+
109+ // Register adds a tool, panicking on a duplicate name.
110+ func (r *Registry) Register(ts ...Tool) *Registry {
111+ r.mu.Lock()
112+ defer r.mu.Unlock()
113+ for _, t := range ts {
114+ if _, dup := r.byName[t.Name()]; dup {
115+ panic(fmt.Sprintf("tools: duplicate registration of %q", t.Name()))
116+ }
117+ r.byName[t.Name()] = t
118+ r.order = append(r.order, t.Name())
119+ }
120+ return r
121+ }
122+
123+ // Get returns the named tool.
124+ func (r *Registry) Get(name string) (Tool, bool) {
125+ r.mu.RLock()
126+ defer r.mu.RUnlock()
127+ t, ok := r.byName[name]
128+ return t, ok
129+ }
130+
131+ // List returns the tools in a stable order, so the tool-definition block of the
132+ // prompt stays byte-identical between turns and keeps the cache prefix warm.
133+ func (r *Registry) List() []Tool {
134+ r.mu.RLock()
135+ defer r.mu.RUnlock()
136+ names := append([]string(nil), r.order...)
137+ sort.Strings(names)
138+ out := make([]Tool, 0, len(names))
139+ for _, n := range names {
140+ out = append(out, r.byName[n])
141+ }
142+ return out
143+ }
144+
145+ // Execute dispatches by name and logs the outcome. An unknown tool is reported
146+ // back to the model as an error rather than failing the turn.
147+ func (r *Registry) Execute(ctx context.Context, name string, raw json.RawMessage) (string, error) {
148+ t, ok := r.Get(name)
149+ if !ok {
150+ err := fmt.Errorf("unknown tool %q", name)
151+ r.log.ToolCall(name, 0, err, nil)
152+ return "", err
153+ }
154+ start := time.Now()
155+ out, err := t.Execute(ctx, raw)
156+ r.log.ToolCall(name, time.Since(start), err, map[string]any{"output_bytes": len(out)})
157+ return out, err
158+ }
+195−0internal/tools/registry_test.goaddedView File
@@ -0,0 +1,195 @@
1+ package tools
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "strings"
9+ "testing"
10+
11+ "harness/internal/metrics"
12+ )
13+
14+ type echoArgs struct {
15+ Text string `json:"text" jsonschema:"description=Text to echo back"`
16+ Times int `json:"times,omitempty" jsonschema:"description=How many times to repeat it"`
17+ }
18+
19+ func echoTool(fail bool) Tool {
20+ return New("echo", "Echo text back", func(_ context.Context, in echoArgs) (string, error) {
21+ if fail {
22+ return "", errors.New("echo refused")
23+ }
24+ n := in.Times
25+ if n == 0 {
26+ n = 1
27+ }
28+ return strings.Repeat(in.Text, n), nil
29+ })
30+ }
31+
32+ func TestSchemaForReflectsStructTags(t *testing.T) {
33+ var got struct {
34+ Type string `json:"type"`
35+ Required []string `json:"required"`
36+ Properties map[string]struct {
37+ Type string `json:"type"`
38+ Description string `json:"description"`
39+ } `json:"properties"`
40+ AdditionalProperties *bool `json:"additionalProperties"`
41+ Schema json.RawMessage `json:"$schema"`
42+ }
43+ if err := json.Unmarshal(SchemaFor[echoArgs](), &got); err != nil {
44+ t.Fatalf("schema is not valid JSON: %v", err)
45+ }
46+ if got.Type != "object" {
47+ t.Errorf("type = %q, want object", got.Type)
48+ }
49+ if len(got.Required) != 1 || got.Required[0] != "text" {
50+ t.Errorf("required = %v, want [text] (omitempty fields must be optional)", got.Required)
51+ }
52+ if got.Properties["times"].Type != "integer" {
53+ t.Errorf("times type = %q, want integer", got.Properties["times"].Type)
54+ }
55+ if got.Properties["text"].Description != "Text to echo back" {
56+ t.Errorf("description not carried over: %q", got.Properties["text"].Description)
57+ }
58+ if got.Schema != nil {
59+ t.Errorf("$schema should be stripped, got %s", got.Schema)
60+ }
61+ }
62+
63+ func TestSchemaForNoFields(t *testing.T) {
64+ type empty struct{}
65+ var got map[string]any
66+ if err := json.Unmarshal(SchemaFor[empty](), &got); err != nil {
67+ t.Fatalf("invalid JSON: %v", err)
68+ }
69+ if got["type"] != "object" {
70+ t.Errorf("type = %v, want object", got["type"])
71+ }
72+ if _, ok := got["properties"]; !ok {
73+ t.Error("properties must be present even when empty")
74+ }
75+ }
76+
77+ func TestTypedExecute(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ raw string
81+ want string
82+ wantErr string
83+ }{
84+ {name: "decodes typed args", raw: `{"text":"ab","times":3}`, want: "ababab"},
85+ {name: "omitted optional field", raw: `{"text":"x"}`, want: "x"},
86+ {name: "empty input treated as {}", raw: ``, want: ""},
87+ {name: "malformed JSON", raw: `{"text":`, wantErr: "invalid arguments"},
88+ {name: "wrong field type", raw: `{"text":"x","times":"three"}`, wantErr: "invalid arguments"},
89+ }
90+ for _, tc := range tests {
91+ t.Run(tc.name, func(t *testing.T) {
92+ got, err := echoTool(false).Execute(context.Background(), json.RawMessage(tc.raw))
93+ switch {
94+ case tc.wantErr != "" && err == nil:
95+ t.Fatalf("want error containing %q, got none (result %q)", tc.wantErr, got)
96+ case tc.wantErr != "":
97+ if !strings.Contains(err.Error(), tc.wantErr) {
98+ t.Fatalf("error = %v, want it to contain %q", err, tc.wantErr)
99+ }
100+ case err != nil:
101+ t.Fatalf("unexpected error: %v", err)
102+ case got != tc.want:
103+ t.Fatalf("result = %q, want %q", got, tc.want)
104+ }
105+ })
106+ }
107+ }
108+
109+ func TestRegistryDispatch(t *testing.T) {
110+ r := NewRegistry(nil).Register(echoTool(false))
111+
112+ got, err := r.Execute(context.Background(), "echo", json.RawMessage(`{"text":"hi"}`))
113+ if err != nil || got != "hi" {
114+ t.Fatalf("Execute = (%q, %v), want (\"hi\", nil)", got, err)
115+ }
116+
117+ if _, err := r.Execute(context.Background(), "nope", nil); err == nil {
118+ t.Error("unknown tool must return an error the model can read")
119+ } else if !strings.Contains(err.Error(), "nope") {
120+ t.Errorf("error should name the tool, got %v", err)
121+ }
122+ }
123+
124+ func TestRegistryToolErrorIsReturnedNotSwallowed(t *testing.T) {
125+ r := NewRegistry(nil).Register(echoTool(true))
126+ out, err := r.Execute(context.Background(), "echo", json.RawMessage(`{"text":"hi"}`))
127+ if err == nil {
128+ t.Fatal("want the tool's error to surface")
129+ }
130+ if out != "" {
131+ t.Errorf("output = %q, want empty on failure", out)
132+ }
133+ if err.Error() != "echo refused" {
134+ t.Errorf("error = %q, want it passed through verbatim", err)
135+ }
136+ }
137+
138+ func TestRegistryListIsStable(t *testing.T) {
139+ r := NewRegistry(nil).Register(
140+ New("zulu", "", func(context.Context, echoArgs) (string, error) { return "", nil }),
141+ New("alpha", "", func(context.Context, echoArgs) (string, error) { return "", nil }),
142+ )
143+ var names []string
144+ for _, tl := range r.List() {
145+ names = append(names, tl.Name())
146+ }
147+ if len(names) != 2 || names[0] != "alpha" || names[1] != "zulu" {
148+ t.Errorf("List = %v, want sorted [alpha zulu] for a byte-stable prompt prefix", names)
149+ }
150+ }
151+
152+ func TestRegistryDuplicatePanics(t *testing.T) {
153+ defer func() {
154+ if recover() == nil {
155+ t.Error("registering the same name twice must panic at wiring time")
156+ }
157+ }()
158+ NewRegistry(nil).Register(echoTool(false), echoTool(false))
159+ }
160+
161+ func TestExecuteRespectsContext(t *testing.T) {
162+ blocked := New("block", "", func(ctx context.Context, _ echoArgs) (string, error) {
163+ <-ctx.Done()
164+ return "", ctx.Err()
165+ })
166+ ctx, cancel := context.WithCancel(context.Background())
167+ cancel()
168+ if _, err := NewRegistry(nil).Register(blocked).Execute(ctx, "block", nil); !errors.Is(err, context.Canceled) {
169+ t.Errorf("error = %v, want context.Canceled", err)
170+ }
171+ }
172+
173+ func TestRegistryLogsEveryDispatch(t *testing.T) {
174+ var buf bytes.Buffer
175+ r := NewRegistry(metrics.NewWriter(&buf, "sess")).Register(echoTool(false))
176+ r.Execute(context.Background(), "echo", json.RawMessage(`{"text":"hi"}`))
177+ r.Execute(context.Background(), "missing", nil)
178+
179+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
180+ if len(lines) != 2 {
181+ t.Fatalf("got %d log lines, want one per dispatch:\n%s", len(lines), buf.String())
182+ }
183+ var ok struct {
184+ Tool string `json:"tool"`
185+ OK bool `json:"ok"`
186+ Out int `json:"output_bytes"`
187+ }
188+ json.Unmarshal([]byte(lines[0]), &ok)
189+ if ok.Tool != "echo" || !ok.OK || ok.Out != 2 {
190+ t.Errorf("success event = %+v, want echo/ok/2 bytes", ok)
191+ }
192+ if !strings.Contains(lines[1], `"ok":false`) {
193+ t.Errorf("unknown-tool dispatch should log a failure, got %s", lines[1])
194+ }
195+ }