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>
Showing 10 changed files with 1045 additions and 0 deletions.
| @@ -0,0 +1,3 @@ | ||
| 1 | + /.harness/ | |
| 2 | + /evals/runs/ | |
| 3 | + /harness | |
| @@ -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. | |
| @@ -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. | |
| @@ -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 | + } | |
| @@ -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 | + ) | |
| @@ -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= | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |