Vectrum
Sign In

Phase 1: Messages API client, turn loop, read_file

Hand-rolled llm package rather than anthropic-sdk-go, per the plan: the
wire format is the thing being learned, and Phase 5 accumulates streaming
tool-call JSON by hand.

ContentBlock marshals only the fields belonging to its type. A blanket
struct marshal sends an empty "thinking" key on every text block, and
thinking blocks need the opposite - "thinking":"" must be sent, because
with display omitted (the default on Opus 5) the text is empty but the
signature has to come back unchanged or the next request is rejected.
Block types the struct does not model round-trip via their raw bytes.

The loop returns one tool_result per tool_use id in a single user
message, including for failures (is_error), since a missing result is an
API error and splitting results across messages suppresses parallel tool
calls. pause_turn resends, refusal surfaces its category, max_tokens is
reported rather than silently truncated.

agent does not import ui: events go to an Observe callback carrying plain
data, so the Phase 3 eval runner can drive the loop headless.

Verified end to end through the built binary against a fake API that
fails if the thinking signature is not echoed back.

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

Showing 13 changed files with 2028 additions and 13 deletions.

+69−13cmd/harness/main.goView File
@@ -1,17 +1,21 @@
1- // Command harness is the CLI entry point: flag parsing, wiring, and (from
2- // Phase 1) the REPL.
1+ // Command harness is the CLI entry point: flag parsing, wiring, and the REPL.
32 package main
43
54 import (
6- "encoding/json"
5+ "bufio"
6+ "context"
77 "flag"
88 "fmt"
99 "os"
1010 "path/filepath"
11+ "strings"
1112 "time"
1213
14+ "harness/internal/agent"
15+ "harness/internal/llm"
1316 "harness/internal/metrics"
1417 "harness/internal/tools"
18+ "harness/internal/ui"
1519 )
1620
1721 func main() {
@@ -23,7 +27,12 @@ func main() {
2327
2428 func run() error {
2529 workdir := flag.String("C", ".", "working root the agent operates in")
26- logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path")
30+ model := flag.String("model", llm.DefaultModel, "model id")
31+ effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
32+ maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt")
33+ logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
34+ prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
35+ verbose := flag.Bool("v", false, "print full tool output")
2736 flag.Parse()
2837
2938 root, err := filepath.Abs(*workdir)
@@ -37,17 +46,64 @@ func run() error {
3746 return err
3847 }
3948 defer log.Close()
40- log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{"root": root}})
49+ log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{"root": root, "model": *model}})
4150
42- reg := tools.NewRegistry(log)
43- // Tools land here in Phase 1 (read_file) and Phase 2 (the rest).
51+ client := llm.New(llm.WithModel(*model))
52+ if !client.HasCredentials() {
53+ return fmt.Errorf("no credentials: export ANTHROPIC_API_KEY, or run `ant auth login`")
54+ }
55+
56+ state := agent.NewState()
57+ reg := tools.NewRegistry(log).Register(
58+ tools.NewRead(root, state),
59+ )
60+
61+ render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
62+ a := agent.New(client, reg, state, log, agent.Options{MaxTurns: *maxTurns, Effort: *effort})
63+ a.Observe = render.Handle
64+
65+ ctx := context.Background()
66+ if *prompt != "" {
67+ return once(ctx, a, render, *prompt)
68+ }
69+
70+ fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, or ctrl-d to exit\n\n",
71+ *model, root, strings.Join(toolNames(reg), ", "))
72+
73+ in := bufio.NewScanner(os.Stdin)
74+ in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
75+ for {
76+ fmt.Print("> ")
77+ if !in.Scan() {
78+ fmt.Println()
79+ return in.Err()
80+ }
81+ line := strings.TrimSpace(in.Text())
82+ if line == "" {
83+ continue
84+ }
85+ if line == "/exit" || line == "/quit" {
86+ return nil
87+ }
88+ if err := once(ctx, a, render, line); err != nil {
89+ render.Error(err)
90+ }
91+ fmt.Println()
92+ }
93+ }
94+
95+ // once runs a single prompt and prints the session totals afterwards.
96+ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, prompt string) error {
97+ _, err := a.Run(ctx, prompt)
98+ usage, turns := a.State().Usage()
99+ render.Usage(usage, turns)
100+ return err
101+ }
44102
45- fmt.Printf("harness session %s\nroot: %s\nmetrics: %s\ntools: %d registered\n",
46- session, root, filepath.Join(root, *logPath), len(reg.List()))
103+ func toolNames(reg *tools.Registry) []string {
104+ var names []string
47105 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)
106+ names = append(names, t.Name())
50107 }
51- fmt.Println("\nno agent loop yet — Phase 1.")
52- return nil
108+ return names
53109 }
+223−0internal/agent/loop.goaddedView File
@@ -0,0 +1,223 @@
1+ package agent
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "strings"
9+
10+ "harness/internal/llm"
11+ "harness/internal/metrics"
12+ "harness/internal/tools"
13+ )
14+
15+ // DefaultMaxTurns caps one Run. An agent that loops without converging burns
16+ // money quickly, so the cap is low and the message when it trips is explicit.
17+ const DefaultMaxTurns = 30
18+
19+ // ErrMaxTurns is returned when the turn cap is hit.
20+ var ErrMaxTurns = errors.New("turn limit reached")
21+
22+ // defaultSystem is deliberately static: it is the first thing in the cached
23+ // prompt prefix, so a timestamp or session id here would cost a cache hit on
24+ // every single request.
25+ const defaultSystem = `You are a coding agent operating on a local repository through tools.
26+
27+ Work by reading before you act. Prefer reading the specific file or region you
28+ need over dumping whole directories. When you change something, verify it by
29+ running the project's own tests rather than asserting that it works.
30+
31+ If a tool returns an error, read it: the error text usually says exactly what to
32+ do differently. Do not repeat a failing call unchanged.
33+
34+ Be concise. When the task is done, say what you changed and why, briefly.`
35+
36+ // Options configures an Agent.
37+ type Options struct {
38+ MaxTurns int
39+ System string
40+ // Effort maps to output_config.effort. Empty means the API default (high).
41+ Effort string
42+ }
43+
44+ // EventKind identifies what happened, for an observer to render.
45+ type EventKind string
46+
47+ const (
48+ EventTurnStart EventKind = "turn_start"
49+ EventText EventKind = "text"
50+ EventToolStart EventKind = "tool_start"
51+ EventToolResult EventKind = "tool_result"
52+ EventUsage EventKind = "usage"
53+ )
54+
55+ // Event is what the loop reports to an observer. It carries plain data only —
56+ // no terminal concerns — so the eval runner can consume the same stream.
57+ type Event struct {
58+ Kind EventKind
59+ Turn int
60+ Text string
61+ Tool string
62+ Input json.RawMessage
63+ Result string
64+ IsError bool
65+ Usage llm.Usage
66+ }
67+
68+ // Agent runs the turn loop.
69+ type Agent struct {
70+ client *llm.Client
71+ tools *tools.Registry
72+ state *State
73+ log *metrics.Logger
74+ opts Options
75+
76+ // Observe is called for every event. It may be nil.
77+ Observe func(Event)
78+ }
79+
80+ // New wires an agent. state and log may be freshly created by the caller so the
81+ // eval runner can keep one per run.
82+ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Logger, opts Options) *Agent {
83+ if opts.MaxTurns <= 0 {
84+ opts.MaxTurns = DefaultMaxTurns
85+ }
86+ if opts.System == "" {
87+ opts.System = defaultSystem
88+ }
89+ return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
90+ }
91+
92+ // State exposes the conversation for callers that need to inspect or persist it.
93+ func (a *Agent) State() *State { return a.state }
94+
95+ func (a *Agent) emit(ev Event) {
96+ if a.Observe != nil {
97+ a.Observe(ev)
98+ }
99+ }
100+
101+ // Run sends one user message and drives the tool loop until the model stops
102+ // asking for tools. It returns the final assistant text.
103+ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
104+ a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}})
105+
106+ for turn := 1; turn <= a.opts.MaxTurns; turn++ {
107+ a.emit(Event{Kind: EventTurnStart, Turn: turn})
108+
109+ resp, err := a.client.CreateMessage(ctx, a.request())
110+ if err != nil {
111+ return "", err
112+ }
113+ a.state.AddUsage(resp.Usage)
114+ a.log.Log(metrics.Event{
115+ Type: "turn",
116+ Turn: turn,
117+ StopReason: resp.StopReason,
118+ InputTokens: resp.Usage.InputTokens,
119+ OutputTokens: resp.Usage.OutputTokens,
120+ CacheReadTokens: resp.Usage.CacheReadInputTokens,
121+ CacheWriteTokens: resp.Usage.CacheCreationInputTokens,
122+ })
123+ a.emit(Event{Kind: EventUsage, Turn: turn, Usage: resp.Usage})
124+
125+ // The assistant message goes into history verbatim, thinking blocks and
126+ // all: their signatures must come back unchanged on the next request.
127+ a.state.Append(resp.Message())
128+
129+ if text := resp.Text(); text != "" {
130+ a.emit(Event{Kind: EventText, Turn: turn, Text: text})
131+ }
132+
133+ switch resp.StopReason {
134+ case llm.StopToolUse:
135+ results, err := a.dispatch(ctx, turn, resp.ToolUses())
136+ if err != nil {
137+ return "", err
138+ }
139+ // Every tool_result for this assistant turn goes back in ONE user
140+ // message. Splitting them across messages is an API error, and even
141+ // where it is not it teaches the model to stop calling tools in
142+ // parallel.
143+ a.state.Append(llm.Message{Role: llm.RoleUser, Content: results})
144+
145+ case llm.StopPauseTurn:
146+ // A server-side tool needs another round trip; resend as-is.
147+ continue
148+
149+ case llm.StopRefusal:
150+ return "", refusalError(resp)
151+
152+ case llm.StopMaxTokens:
153+ return resp.Text(), fmt.Errorf("response hit max_tokens (%d) and was cut off", a.client.MaxTokens)
154+
155+ default:
156+ return resp.Text(), nil
157+ }
158+ }
159+
160+ return "", fmt.Errorf("%w: stopped after %d turns without finishing; narrow the task or raise -max-turns",
161+ ErrMaxTurns, a.opts.MaxTurns)
162+ }
163+
164+ // dispatch runs each tool call in order and builds one tool_result per
165+ // tool_use id. A tool that fails still produces a result — a missing one is an
166+ // API error, and the model can usually recover from the error text.
167+ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) {
168+ results := make([]llm.ContentBlock, 0, len(calls))
169+ for _, call := range calls {
170+ a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, Input: call.Input})
171+
172+ out, err := a.tools.Execute(ctx, call.Name, call.Input)
173+ if ctx.Err() != nil {
174+ return nil, ctx.Err()
175+ }
176+
177+ isErr := err != nil
178+ payload := out
179+ if isErr {
180+ payload = err.Error()
181+ }
182+ results = append(results, llm.ToolResultBlock(call.ID, payload, isErr))
183+ a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr})
184+ }
185+ return results, nil
186+ }
187+
188+ // request builds the body. Order matters for prompt caching: system, then
189+ // tools, then the growing conversation.
190+ func (a *Agent) request() llm.Request {
191+ defs := make([]llm.ToolDef, 0, len(a.tools.List()))
192+ for _, t := range a.tools.List() {
193+ defs = append(defs, llm.ToolDef{
194+ Name: t.Name(),
195+ Description: t.Description(),
196+ InputSchema: t.InputSchema(),
197+ })
198+ }
199+ req := llm.Request{
200+ System: []llm.SystemBlock{{Type: "text", Text: a.opts.System}},
201+ Messages: a.state.History(),
202+ Tools: defs,
203+ Thinking: llm.Adaptive(),
204+ }
205+ if a.opts.Effort != "" {
206+ req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort}
207+ }
208+ return req
209+ }
210+
211+ func refusalError(resp *llm.Response) error {
212+ var b strings.Builder
213+ b.WriteString("the model declined this request")
214+ if resp.StopDetails != nil {
215+ if resp.StopDetails.Category != "" {
216+ fmt.Fprintf(&b, " (%s)", resp.StopDetails.Category)
217+ }
218+ if resp.StopDetails.Explanation != "" {
219+ fmt.Fprintf(&b, ": %s", resp.StopDetails.Explanation)
220+ }
221+ }
222+ return errors.New(b.String())
223+ }
+360−0internal/agent/loop_test.goaddedView File
@@ -0,0 +1,360 @@
1+ package agent
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "testing"
15+
16+ "harness/internal/llm"
17+ "harness/internal/metrics"
18+ "harness/internal/tools"
19+ )
20+
21+ // fakeAPI serves a scripted sequence of responses and records every request
22+ // body it received, so tests can assert on what the loop actually sent.
23+ type fakeAPI struct {
24+ t *testing.T
25+ responses []string
26+ requests []llm.Request
27+ srv *httptest.Server
28+ }
29+
30+ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI {
31+ f := &fakeAPI{t: t, responses: responses}
32+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ body, _ := io.ReadAll(r.Body)
34+ var req llm.Request
35+ if err := json.Unmarshal(body, &req); err != nil {
36+ t.Errorf("harness sent a body the API types cannot parse: %v\n%s", err, body)
37+ }
38+ f.requests = append(f.requests, req)
39+ i := len(f.requests) - 1
40+ if i >= len(f.responses) {
41+ t.Errorf("harness made %d requests, script only has %d", i+1, len(f.responses))
42+ w.WriteHeader(http.StatusInternalServerError)
43+ return
44+ }
45+ io.WriteString(w, f.responses[i])
46+ }))
47+ t.Cleanup(f.srv.Close)
48+ return f
49+ }
50+
51+ // rawRequest returns the n-th request body re-encoded, for byte-level assertions.
52+ func (f *fakeAPI) rawRequest(n int) string {
53+ b, err := json.Marshal(f.requests[n])
54+ if err != nil {
55+ f.t.Fatal(err)
56+ }
57+ return string(b)
58+ }
59+
60+ func msg(stopReason string, blocks ...string) string {
61+ return `{"id":"msg","type":"message","role":"assistant","model":"claude-opus-5","content":[` +
62+ strings.Join(blocks, ",") + `],"stop_reason":"` + stopReason + `",` +
63+ `"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":40}}`
64+ }
65+
66+ func textBlock(s string) string { return `{"type":"text","text":"` + s + `"}` }
67+
68+ func toolUseBlock(id, name, input string) string {
69+ return `{"type":"tool_use","id":"` + id + `","name":"` + name + `","input":` + input + `}`
70+ }
71+
72+ // newTestAgent wires an agent against the fake API, with read_file rooted at
73+ // a temp dir plus a couple of tools that make failure modes easy to trigger.
74+ func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) *Agent {
75+ t.Helper()
76+ log := metrics.NewWriter(logBuf, "test")
77+ state := NewState()
78+ reg := tools.NewRegistry(log).Register(
79+ tools.NewRead(root, state),
80+ tools.New("boom", "Always fails", func(context.Context, struct{}) (string, error) {
81+ return "", errors.New("detonated")
82+ }),
83+ )
84+ client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"))
85+ return New(client, reg, state, log, Options{})
86+ }
87+
88+ func TestRunReadsAFileAndAnswers(t *testing.T) {
89+ root := t.TempDir()
90+ os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
91+
92+ f := newFakeAPI(t,
93+ msg("tool_use", textBlock("Let me look."), toolUseBlock("tu_1", "read_file", `{"path":"main.go"}`)),
94+ msg("end_turn", textBlock("It declares package main.")),
95+ )
96+ a := newTestAgent(t, f, root, &bytes.Buffer{})
97+
98+ got, err := a.Run(context.Background(), "what does main.go do?")
99+ if err != nil {
100+ t.Fatalf("Run: %v", err)
101+ }
102+ if got != "It declares package main." {
103+ t.Errorf("answer = %q", got)
104+ }
105+ if len(f.requests) != 2 {
106+ t.Fatalf("made %d requests, want 2", len(f.requests))
107+ }
108+
109+ // Second request: user prompt, assistant turn, then the tool results.
110+ second := f.requests[1].Messages
111+ if len(second) != 3 {
112+ t.Fatalf("second request had %d messages, want 3:\n%s", len(second), f.rawRequest(1))
113+ }
114+ if second[2].Role != llm.RoleUser {
115+ t.Errorf("tool results must come back under the user role, got %q", second[2].Role)
116+ }
117+ if n := len(second[2].Content); n != 1 {
118+ t.Fatalf("want 1 tool_result block, got %d", n)
119+ }
120+ res := second[2].Content[0]
121+ if res.Type != llm.BlockToolResult || res.ToolUseID != "tu_1" || res.IsError {
122+ t.Errorf("tool_result = %+v, want a successful result for tu_1", res)
123+ }
124+ if !strings.Contains(res.Content, "package main") {
125+ t.Errorf("tool_result content = %q, want the file contents", res.Content)
126+ }
127+ }
128+
129+ func TestParallelToolCallsReturnInOneUserMessage(t *testing.T) {
130+ root := t.TempDir()
131+ os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644)
132+ os.WriteFile(filepath.Join(root, "b.go"), []byte("B\n"), 0o644)
133+
134+ f := newFakeAPI(t,
135+ msg("tool_use",
136+ toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`),
137+ toolUseBlock("tu_2", "read_file", `{"path":"b.go"}`)),
138+ msg("end_turn", textBlock("done")),
139+ )
140+ a := newTestAgent(t, f, root, &bytes.Buffer{})
141+ if _, err := a.Run(context.Background(), "read both"); err != nil {
142+ t.Fatalf("Run: %v", err)
143+ }
144+
145+ last := f.requests[1].Messages[len(f.requests[1].Messages)-1]
146+ if len(last.Content) != 2 {
147+ t.Fatalf("want both results in ONE message, got %d messages ending with %d blocks",
148+ len(f.requests[1].Messages), len(last.Content))
149+ }
150+ if last.Content[0].ToolUseID != "tu_1" || last.Content[1].ToolUseID != "tu_2" {
151+ t.Errorf("results out of order: %s, %s", last.Content[0].ToolUseID, last.Content[1].ToolUseID)
152+ }
153+ }
154+
155+ func TestFailedToolStillProducesAResult(t *testing.T) {
156+ tests := []struct {
157+ name string
158+ call string
159+ wantText string
160+ }{
161+ {name: "tool returns an error", call: toolUseBlock("tu_1", "boom", `{}`), wantText: "detonated"},
162+ {name: "unknown tool", call: toolUseBlock("tu_1", "telepathy", `{}`), wantText: `unknown tool "telepathy"`},
163+ {name: "malformed arguments", call: toolUseBlock("tu_1", "read_file", `{"path":42}`), wantText: "invalid arguments"},
164+ }
165+ for _, tc := range tests {
166+ t.Run(tc.name, func(t *testing.T) {
167+ f := newFakeAPI(t, msg("tool_use", tc.call), msg("end_turn", textBlock("ok")))
168+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
169+ if _, err := a.Run(context.Background(), "go"); err != nil {
170+ t.Fatalf("a failing tool is a normal event, not a Run error: %v", err)
171+ }
172+ last := f.requests[1].Messages[len(f.requests[1].Messages)-1]
173+ if len(last.Content) != 1 {
174+ t.Fatalf("every tool_use id needs exactly one result, got %d", len(last.Content))
175+ }
176+ res := last.Content[0]
177+ if !res.IsError {
178+ t.Error("is_error must be set so the model knows the call failed")
179+ }
180+ if !strings.Contains(res.Content, tc.wantText) {
181+ t.Errorf("result = %q, want it to contain %q", res.Content, tc.wantText)
182+ }
183+ })
184+ }
185+ }
186+
187+ // Thinking signatures must be echoed back byte-identical or the next request
188+ // is rejected.
189+ func TestThinkingBlocksAreEchoedBackUnchanged(t *testing.T) {
190+ f := newFakeAPI(t,
191+ msg("tool_use",
192+ `{"type":"thinking","thinking":"","signature":"sig-xyz"}`,
193+ toolUseBlock("tu_1", "boom", `{}`)),
194+ msg("end_turn", textBlock("fine")),
195+ )
196+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
197+ if _, err := a.Run(context.Background(), "go"); err != nil {
198+ t.Fatalf("Run: %v", err)
199+ }
200+ body := f.rawRequest(1)
201+ if !strings.Contains(body, `"signature":"sig-xyz"`) {
202+ t.Errorf("thinking signature was dropped from the resent history:\n%s", body)
203+ }
204+ if !strings.Contains(body, `"thinking":""`) {
205+ t.Errorf("empty thinking text was dropped:\n%s", body)
206+ }
207+ }
208+
209+ func TestRequestShape(t *testing.T) {
210+ f := newFakeAPI(t, msg("end_turn", textBlock("hi")))
211+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
212+ if _, err := a.Run(context.Background(), "hello"); err != nil {
213+ t.Fatalf("Run: %v", err)
214+ }
215+ req := f.requests[0]
216+ if len(req.System) != 1 || req.System[0].Text == "" {
217+ t.Error("system prompt missing")
218+ }
219+ if strings.ContainsAny(req.System[0].Text, "0123456789") {
220+ t.Error("system prompt should hold no digits: timestamps or ids here would break the cached prefix")
221+ }
222+ if req.Thinking == nil || req.Thinking.Type != "adaptive" {
223+ t.Errorf("thinking = %+v, want adaptive (budget_tokens is a 400 on current models)", req.Thinking)
224+ }
225+ if len(req.Tools) != 2 || req.Tools[0].Name != "boom" || req.Tools[1].Name != "read_file" {
226+ t.Errorf("tools = %+v, want them sorted for a byte-stable prefix", req.Tools)
227+ }
228+ for _, tl := range req.Tools {
229+ if len(tl.InputSchema) == 0 {
230+ t.Errorf("tool %q has no input_schema", tl.Name)
231+ }
232+ }
233+ }
234+
235+ func TestMaxTurnsStopsTheLoop(t *testing.T) {
236+ var script []string
237+ for i := 0; i < 5; i++ {
238+ script = append(script, msg("tool_use", toolUseBlock("tu", "boom", `{}`)))
239+ }
240+ f := newFakeAPI(t, script...)
241+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
242+ a.opts.MaxTurns = 3
243+
244+ _, err := a.Run(context.Background(), "loop forever")
245+ if !errors.Is(err, ErrMaxTurns) {
246+ t.Fatalf("error = %v, want ErrMaxTurns", err)
247+ }
248+ if !strings.Contains(err.Error(), "3 turns") {
249+ t.Errorf("error should say how many turns were spent: %v", err)
250+ }
251+ if len(f.requests) != 3 {
252+ t.Errorf("made %d requests, want exactly the cap of 3", len(f.requests))
253+ }
254+ }
255+
256+ func TestRefusalIsReportedWithItsCategory(t *testing.T) {
257+ f := newFakeAPI(t, `{"id":"m","type":"message","role":"assistant","model":"claude-opus-5",
258+ "content":[],"stop_reason":"refusal","stop_details":{"type":"refusal","category":"cyber","explanation":"nope"},
259+ "usage":{"input_tokens":5,"output_tokens":0}}`)
260+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
261+ _, err := a.Run(context.Background(), "do something")
262+ if err == nil {
263+ t.Fatal("want an error")
264+ }
265+ if !strings.Contains(err.Error(), "cyber") || !strings.Contains(err.Error(), "nope") {
266+ t.Errorf("error = %v, want the category and explanation", err)
267+ }
268+ }
269+
270+ func TestPauseTurnResends(t *testing.T) {
271+ f := newFakeAPI(t,
272+ msg("pause_turn", textBlock("thinking")),
273+ msg("end_turn", textBlock("done")),
274+ )
275+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
276+ got, err := a.Run(context.Background(), "go")
277+ if err != nil {
278+ t.Fatalf("Run: %v", err)
279+ }
280+ if got != "done" || len(f.requests) != 2 {
281+ t.Errorf("got %q after %d requests, want the loop to resend after pause_turn", got, len(f.requests))
282+ }
283+ }
284+
285+ func TestUsageAndEventsAreRecorded(t *testing.T) {
286+ root := t.TempDir()
287+ os.WriteFile(filepath.Join(root, "x.go"), []byte("x\n"), 0o644)
288+ f := newFakeAPI(t,
289+ msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"x.go"}`)),
290+ msg("end_turn", textBlock("done")),
291+ )
292+ var logBuf bytes.Buffer
293+ a := newTestAgent(t, f, root, &logBuf)
294+
295+ var kinds []EventKind
296+ a.Observe = func(ev Event) { kinds = append(kinds, ev.Kind) }
297+ if _, err := a.Run(context.Background(), "go"); err != nil {
298+ t.Fatalf("Run: %v", err)
299+ }
300+
301+ usage, turns := a.State().Usage()
302+ if turns != 2 || usage.InputTokens != 200 || usage.CacheReadInputTokens != 80 {
303+ t.Errorf("usage = %+v over %d turns, want both turns accumulated", usage, turns)
304+ }
305+ if !containsKind(kinds, EventToolStart) || !containsKind(kinds, EventToolResult) || !containsKind(kinds, EventText) {
306+ t.Errorf("observer saw %v, want tool and text events", kinds)
307+ }
308+
309+ log := logBuf.String()
310+ if n := strings.Count(log, `"type":"turn"`); n != 2 {
311+ t.Errorf("logged %d turn events, want 2:\n%s", n, log)
312+ }
313+ if n := strings.Count(log, `"type":"tool_call"`); n != 1 {
314+ t.Errorf("logged %d tool_call events, want 1:\n%s", n, log)
315+ }
316+ if !strings.Contains(log, `"cache_read_input_tokens":40`) {
317+ t.Errorf("cache reads must be logged; that is the Phase 4 lever:\n%s", log)
318+ }
319+ }
320+
321+ func TestReadFileMarksStateSoEditCanCheckLater(t *testing.T) {
322+ root := t.TempDir()
323+ path := filepath.Join(root, "tracked.go")
324+ os.WriteFile(path, []byte("x\n"), 0o644)
325+ f := newFakeAPI(t,
326+ msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"tracked.go"}`)),
327+ msg("end_turn", textBlock("done")),
328+ )
329+ a := newTestAgent(t, f, root, &bytes.Buffer{})
330+ if _, err := a.Run(context.Background(), "go"); err != nil {
331+ t.Fatalf("Run: %v", err)
332+ }
333+ if _, ok := a.State().LastRead(path); !ok {
334+ t.Error("read_file must record the read in State for Phase 2's edit_file")
335+ }
336+ }
337+
338+ func TestRunPropagatesAPIErrors(t *testing.T) {
339+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
340+ w.WriteHeader(http.StatusBadRequest)
341+ io.WriteString(w, `{"error":{"type":"invalid_request_error","message":"bad model"}}`)
342+ }))
343+ defer srv.Close()
344+
345+ state := NewState()
346+ a := New(llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k")),
347+ tools.NewRegistry(nil), state, nil, Options{})
348+ if _, err := a.Run(context.Background(), "go"); err == nil || !strings.Contains(err.Error(), "bad model") {
349+ t.Errorf("error = %v, want the API message surfaced", err)
350+ }
351+ }
352+
353+ func containsKind(kinds []EventKind, want EventKind) bool {
354+ for _, k := range kinds {
355+ if k == want {
356+ return true
357+ }
358+ }
359+ return false
360+ }
+88−0internal/agent/state.goaddedView File
@@ -0,0 +1,88 @@
1+ // Package agent holds the turn loop and the conversation state it runs on.
2+ //
3+ // It must not import the ui package: the eval runner in Phase 3 drives this
4+ // loop headless, and anything terminal-shaped here would make that impossible.
5+ package agent
6+
7+ import (
8+ "sync"
9+ "time"
10+
11+ "harness/internal/llm"
12+ )
13+
14+ // ReadRecord is when a file was last read and what its mtime was at the time.
15+ // Phase 2's edit_file uses this to refuse edits to files the model has not read
16+ // and to force a re-read when the file has changed underneath it.
17+ type ReadRecord struct {
18+ At time.Time
19+ Mod time.Time
20+ }
21+
22+ // State is the conversation history plus the per-session file-read tracking.
23+ type State struct {
24+ mu sync.Mutex
25+ messages []llm.Message
26+ reads map[string]ReadRecord
27+ usage llm.Usage
28+ turns int
29+ }
30+
31+ func NewState() *State {
32+ return &State{reads: map[string]ReadRecord{}}
33+ }
34+
35+ // MarkRead implements tools.ReadTracker.
36+ func (s *State) MarkRead(path string, mod time.Time) {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ s.reads[path] = ReadRecord{At: time.Now(), Mod: mod}
40+ }
41+
42+ // LastRead returns the read record for path, if the file has been read.
43+ func (s *State) LastRead(path string) (ReadRecord, bool) {
44+ s.mu.Lock()
45+ defer s.mu.Unlock()
46+ r, ok := s.reads[path]
47+ return r, ok
48+ }
49+
50+ // Append adds a message to the history.
51+ func (s *State) Append(m llm.Message) {
52+ s.mu.Lock()
53+ defer s.mu.Unlock()
54+ s.messages = append(s.messages, m)
55+ }
56+
57+ // History returns a copy of the conversation so far.
58+ func (s *State) History() []llm.Message {
59+ s.mu.Lock()
60+ defer s.mu.Unlock()
61+ return append([]llm.Message(nil), s.messages...)
62+ }
63+
64+ // Len is the number of messages in the history.
65+ func (s *State) Len() int {
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ return len(s.messages)
69+ }
70+
71+ // AddUsage accumulates the token counts of one response.
72+ func (s *State) AddUsage(u llm.Usage) {
73+ s.mu.Lock()
74+ defer s.mu.Unlock()
75+ s.usage.InputTokens += u.InputTokens
76+ s.usage.OutputTokens += u.OutputTokens
77+ s.usage.CacheReadInputTokens += u.CacheReadInputTokens
78+ s.usage.CacheCreationInputTokens += u.CacheCreationInputTokens
79+ s.turns++
80+ }
81+
82+ // Usage is the running total for the session, and the number of model turns it
83+ // took to get there.
84+ func (s *State) Usage() (llm.Usage, int) {
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+ return s.usage, s.turns
88+ }
+246−0internal/llm/client.goaddedView File
@@ -0,0 +1,246 @@
1+ package llm
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "math"
10+ "net/http"
11+ "os"
12+ "strconv"
13+ "time"
14+ )
15+
16+ const (
17+ DefaultBaseURL = "https://api.anthropic.com"
18+ DefaultModel = "claude-opus-5"
19+ // Non-streaming requests need to finish inside the HTTP timeout; 16k is the
20+ // documented ceiling for that. Phase 5 raises this once we stream.
21+ DefaultMaxTokens = 16000
22+ apiVersion = "2023-06-01"
23+ oauthBeta = "oauth-2025-04-20"
24+ defaultMaxRetries = 2
25+ )
26+
27+ // Client talks to POST /v1/messages.
28+ type Client struct {
29+ BaseURL string
30+ Model string
31+ MaxTokens int
32+ MaxRetries int
33+ // RetryDelay is the base of the exponential backoff between retries.
34+ RetryDelay time.Duration
35+ HTTP *http.Client
36+
37+ apiKey string
38+ authToken string
39+ }
40+
41+ // Option configures a Client.
42+ type Option func(*Client)
43+
44+ func WithBaseURL(u string) Option { return func(c *Client) { c.BaseURL = u } }
45+ func WithModel(m string) Option { return func(c *Client) { c.Model = m } }
46+ func WithMaxTokens(n int) Option { return func(c *Client) { c.MaxTokens = n } }
47+ func WithAPIKey(k string) Option { return func(c *Client) { c.apiKey = k; c.authToken = "" } }
48+ func WithAuthToken(t string) Option { return func(c *Client) { c.authToken = t; c.apiKey = "" } }
49+ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h } }
50+
51+ // New builds a client, reading credentials from the environment unless an
52+ // option overrides them. An unset ANTHROPIC_API_KEY is not fatal here: the
53+ // caller decides, because ANTHROPIC_AUTH_TOKEN or an `ant auth login` profile
54+ // may be in play.
55+ func New(opts ...Option) *Client {
56+ c := &Client{
57+ BaseURL: DefaultBaseURL,
58+ Model: DefaultModel,
59+ MaxTokens: DefaultMaxTokens,
60+ MaxRetries: defaultMaxRetries,
61+ RetryDelay: 500 * time.Millisecond,
62+ HTTP: &http.Client{Timeout: 10 * time.Minute},
63+ apiKey: os.Getenv("ANTHROPIC_API_KEY"),
64+ authToken: os.Getenv("ANTHROPIC_AUTH_TOKEN"),
65+ }
66+ if c.apiKey != "" {
67+ c.authToken = ""
68+ }
69+ if u := os.Getenv("ANTHROPIC_BASE_URL"); u != "" {
70+ c.BaseURL = u
71+ }
72+ for _, o := range opts {
73+ o(c)
74+ }
75+ return c
76+ }
77+
78+ // HasCredentials reports whether the client can authenticate.
79+ func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" }
80+
81+ // APIError is a non-2xx response.
82+ type APIError struct {
83+ StatusCode int
84+ Type string
85+ Message string
86+ Body string
87+ }
88+
89+ func (e *APIError) Error() string {
90+ if e.Message != "" {
91+ return fmt.Sprintf("anthropic: %d %s: %s", e.StatusCode, e.Type, e.Message)
92+ }
93+ return fmt.Sprintf("anthropic: %d: %s", e.StatusCode, e.Body)
94+ }
95+
96+ // Retryable reports whether resending the identical request could succeed.
97+ func (e *APIError) Retryable() bool {
98+ return e.StatusCode == http.StatusTooManyRequests ||
99+ e.StatusCode == http.StatusRequestTimeout ||
100+ e.StatusCode == http.StatusConflict ||
101+ e.StatusCode >= 500
102+ }
103+
104+ // CreateMessage sends one request, filling in the client's defaults and
105+ // retrying the errors worth retrying.
106+ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, error) {
107+ // Checked once, up front: a missing credential is permanent, and retrying
108+ // it three times with backoff just delays the error.
109+ if !c.HasCredentials() {
110+ return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
111+ }
112+ if req.Model == "" {
113+ req.Model = c.Model
114+ }
115+ if req.MaxTokens == 0 {
116+ req.MaxTokens = c.MaxTokens
117+ }
118+ body, err := json.Marshal(req)
119+ if err != nil {
120+ return nil, fmt.Errorf("llm: encoding request: %w", err)
121+ }
122+
123+ var lastErr error
124+ for attempt := 0; attempt <= c.MaxRetries; attempt++ {
125+ if attempt > 0 {
126+ if err := sleep(ctx, c.backoff(attempt, lastErr)); err != nil {
127+ return nil, err
128+ }
129+ }
130+ resp, err := c.attempt(ctx, body)
131+ if err == nil {
132+ return resp, nil
133+ }
134+ lastErr = err
135+ if ctx.Err() != nil {
136+ return nil, ctx.Err()
137+ }
138+ var apiErr *APIError
139+ if ok := asAPIError(err, &apiErr); ok && !apiErr.Retryable() {
140+ return nil, err
141+ }
142+ }
143+ return nil, lastErr
144+ }
145+
146+ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
147+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
148+ if err != nil {
149+ return nil, err
150+ }
151+ httpReq.Header.Set("content-type", "application/json")
152+ httpReq.Header.Set("anthropic-version", apiVersion)
153+ switch {
154+ case c.apiKey != "":
155+ httpReq.Header.Set("x-api-key", c.apiKey)
156+ default:
157+ // OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
158+ httpReq.Header.Set("authorization", "Bearer "+c.authToken)
159+ httpReq.Header.Set("anthropic-beta", oauthBeta)
160+ }
161+
162+ httpResp, err := c.HTTP.Do(httpReq)
163+ if err != nil {
164+ return nil, fmt.Errorf("llm: %w", err)
165+ }
166+ defer httpResp.Body.Close()
167+
168+ raw, err := io.ReadAll(httpResp.Body)
169+ if err != nil {
170+ return nil, fmt.Errorf("llm: reading response: %w", err)
171+ }
172+ if httpResp.StatusCode != http.StatusOK {
173+ apiErr := &APIError{StatusCode: httpResp.StatusCode, Body: string(raw)}
174+ var envelope struct {
175+ Error struct {
176+ Type string `json:"type"`
177+ Message string `json:"message"`
178+ } `json:"error"`
179+ }
180+ if json.Unmarshal(raw, &envelope) == nil {
181+ apiErr.Type = envelope.Error.Type
182+ apiErr.Message = envelope.Error.Message
183+ }
184+ if ra := httpResp.Header.Get("retry-after"); ra != "" {
185+ apiErr.Message += " (retry-after " + ra + "s)"
186+ }
187+ return nil, apiErr
188+ }
189+
190+ var out Response
191+ if err := json.Unmarshal(raw, &out); err != nil {
192+ return nil, fmt.Errorf("llm: decoding response: %w", err)
193+ }
194+ return &out, nil
195+ }
196+
197+ func asAPIError(err error, target **APIError) bool {
198+ if e, ok := err.(*APIError); ok {
199+ *target = e
200+ return true
201+ }
202+ return false
203+ }
204+
205+ // backoff grows exponentially, but honours a retry-after hint when the server
206+ // sent one.
207+ func (c *Client) backoff(attempt int, lastErr error) time.Duration {
208+ var apiErr *APIError
209+ if asAPIError(lastErr, &apiErr) {
210+ if d, ok := retryAfter(apiErr.Message); ok {
211+ return d
212+ }
213+ }
214+ return time.Duration(math.Pow(2, float64(attempt))) * c.RetryDelay
215+ }
216+
217+ func retryAfter(msg string) (time.Duration, bool) {
218+ const marker = "(retry-after "
219+ i := indexOf(msg, marker)
220+ if i < 0 {
221+ return 0, false
222+ }
223+ rest := msg[i+len(marker):]
224+ j := indexOf(rest, "s)")
225+ if j < 0 {
226+ return 0, false
227+ }
228+ secs, err := strconv.Atoi(rest[:j])
229+ if err != nil || secs < 0 {
230+ return 0, false
231+ }
232+ return time.Duration(secs) * time.Second, true
233+ }
234+
235+ func indexOf(s, sub string) int { return bytes.Index([]byte(s), []byte(sub)) }
236+
237+ func sleep(ctx context.Context, d time.Duration) error {
238+ t := time.NewTimer(d)
239+ defer t.Stop()
240+ select {
241+ case <-ctx.Done():
242+ return ctx.Err()
243+ case <-t.C:
244+ return nil
245+ }
246+ }
+186−0internal/llm/client_test.goaddedView File
@@ -0,0 +1,186 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+ )
14+
15+ const okResponse = `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5",
16+ "content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":2}}`
17+
18+ func testClient(t *testing.T, h http.HandlerFunc) *Client {
19+ t.Helper()
20+ srv := httptest.NewServer(h)
21+ t.Cleanup(srv.Close)
22+ c := New(WithBaseURL(srv.URL), WithAPIKey("test-key"))
23+ c.RetryDelay = time.Millisecond // keep the retry tests fast
24+ return c
25+ }
26+
27+ func TestCreateMessageSendsExpectedHeadersAndBody(t *testing.T) {
28+ var gotHeaders http.Header
29+ var gotBody map[string]any
30+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
31+ gotHeaders = r.Header.Clone()
32+ body, _ := io.ReadAll(r.Body)
33+ json.Unmarshal(body, &gotBody)
34+ if r.URL.Path != "/v1/messages" {
35+ t.Errorf("path = %q, want /v1/messages", r.URL.Path)
36+ }
37+ io.WriteString(w, okResponse)
38+ })
39+
40+ resp, err := c.CreateMessage(context.Background(), Request{
41+ Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hello")}}},
42+ })
43+ if err != nil {
44+ t.Fatalf("CreateMessage: %v", err)
45+ }
46+ if resp.Text() != "hi" {
47+ t.Errorf("Text() = %q, want hi", resp.Text())
48+ }
49+ if gotHeaders.Get("x-api-key") != "test-key" {
50+ t.Errorf("x-api-key = %q", gotHeaders.Get("x-api-key"))
51+ }
52+ if gotHeaders.Get("anthropic-version") != apiVersion {
53+ t.Errorf("anthropic-version = %q, want %s", gotHeaders.Get("anthropic-version"), apiVersion)
54+ }
55+ if gotBody["model"] != DefaultModel {
56+ t.Errorf("model = %v, want the client default %s", gotBody["model"], DefaultModel)
57+ }
58+ if gotBody["max_tokens"].(float64) != DefaultMaxTokens {
59+ t.Errorf("max_tokens = %v, want the client default", gotBody["max_tokens"])
60+ }
61+ }
62+
63+ func TestOAuthTokenUsesBearerAndBetaHeader(t *testing.T) {
64+ var got http.Header
65+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ got = r.Header.Clone()
67+ io.WriteString(w, okResponse)
68+ }))
69+ defer srv.Close()
70+
71+ c := New(WithBaseURL(srv.URL), WithAuthToken("oauth-tok"))
72+ if _, err := c.CreateMessage(context.Background(), Request{}); err != nil {
73+ t.Fatalf("CreateMessage: %v", err)
74+ }
75+ if got.Get("authorization") != "Bearer oauth-tok" {
76+ t.Errorf("authorization = %q, want Bearer (OAuth tokens do not go on x-api-key)", got.Get("authorization"))
77+ }
78+ if got.Get("x-api-key") != "" {
79+ t.Errorf("x-api-key should be unset when using an OAuth token, got %q", got.Get("x-api-key"))
80+ }
81+ if got.Get("anthropic-beta") != oauthBeta {
82+ t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta)
83+ }
84+ }
85+
86+ func TestNoCredentialsIsAClearError(t *testing.T) {
87+ c := New(WithBaseURL("http://127.0.0.1:1"))
88+ c.apiKey, c.authToken = "", ""
89+ _, err := c.CreateMessage(context.Background(), Request{})
90+ if err == nil || !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") {
91+ t.Errorf("error = %v, want it to name the env var", err)
92+ }
93+ if c.HasCredentials() {
94+ t.Error("HasCredentials should be false")
95+ }
96+ }
97+
98+ func TestRetriesTransientStatusesAndNotClientErrors(t *testing.T) {
99+ tests := []struct {
100+ name string
101+ status int
102+ wantCalls int32
103+ wantSucceed bool
104+ }{
105+ {name: "429 is retried", status: http.StatusTooManyRequests, wantCalls: 3},
106+ {name: "500 is retried", status: http.StatusInternalServerError, wantCalls: 3},
107+ {name: "400 is not retried", status: http.StatusBadRequest, wantCalls: 1},
108+ {name: "401 is not retried", status: http.StatusUnauthorized, wantCalls: 1},
109+ }
110+ for _, tc := range tests {
111+ t.Run(tc.name, func(t *testing.T) {
112+ var calls int32
113+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
114+ atomic.AddInt32(&calls, 1)
115+ w.WriteHeader(tc.status)
116+ io.WriteString(w, `{"type":"error","error":{"type":"boom","message":"nope"}}`)
117+ })
118+ c.MaxRetries = 2
119+ c.HTTP.Timeout = 5 * time.Second
120+
121+ _, err := c.CreateMessage(context.Background(), Request{})
122+ if err == nil {
123+ t.Fatal("want an error")
124+ }
125+ if got := atomic.LoadInt32(&calls); got != tc.wantCalls {
126+ t.Errorf("server saw %d calls, want %d", got, tc.wantCalls)
127+ }
128+ apiErr, ok := err.(*APIError)
129+ if !ok {
130+ t.Fatalf("error type = %T, want *APIError", err)
131+ }
132+ if apiErr.StatusCode != tc.status || apiErr.Message == "" {
133+ t.Errorf("APIError = %+v, want the status and parsed message", apiErr)
134+ }
135+ })
136+ }
137+ }
138+
139+ func TestRetrySucceedsOnSecondAttempt(t *testing.T) {
140+ var calls int32
141+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
142+ if atomic.AddInt32(&calls, 1) == 1 {
143+ w.WriteHeader(http.StatusServiceUnavailable)
144+ return
145+ }
146+ io.WriteString(w, okResponse)
147+ })
148+ resp, err := c.CreateMessage(context.Background(), Request{})
149+ if err != nil {
150+ t.Fatalf("CreateMessage: %v", err)
151+ }
152+ if resp.Text() != "hi" || atomic.LoadInt32(&calls) != 2 {
153+ t.Errorf("got %q after %d calls, want hi after 2", resp.Text(), calls)
154+ }
155+ }
156+
157+ func TestContextCancellationStopsRetrying(t *testing.T) {
158+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
159+ w.WriteHeader(http.StatusInternalServerError)
160+ })
161+ ctx, cancel := context.WithCancel(context.Background())
162+ cancel()
163+ if _, err := c.CreateMessage(ctx, Request{}); err == nil {
164+ t.Fatal("want an error from the cancelled context")
165+ }
166+ }
167+
168+ func TestMalformedResponseBodyIsReported(t *testing.T) {
169+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
170+ io.WriteString(w, `{"content":`)
171+ })
172+ _, err := c.CreateMessage(context.Background(), Request{})
173+ if err == nil || !strings.Contains(err.Error(), "decoding response") {
174+ t.Errorf("error = %v, want a decode error", err)
175+ }
176+ }
177+
178+ func TestRetryAfterHintIsParsed(t *testing.T) {
179+ got, ok := retryAfter("rate limited (retry-after 7s)")
180+ if !ok || got != 7*time.Second {
181+ t.Errorf("retryAfter = (%v, %v), want (7s, true)", got, ok)
182+ }
183+ if _, ok := retryAfter("rate limited"); ok {
184+ t.Error("want no hint when the header was absent")
185+ }
186+ }
+233−0internal/llm/types.goaddedView File
@@ -0,0 +1,233 @@
1+ // Package llm is a hand-rolled client for the Anthropic Messages API.
2+ //
3+ // The official anthropic-sdk-go would cover most of this; the types are written
4+ // out here because understanding the wire format is the point of the project,
5+ // and Phase 5 hand-accumulates streaming tool-call JSON.
6+ package llm
7+
8+ import (
9+ "encoding/json"
10+ "fmt"
11+ )
12+
13+ // Roles and block types, spelled out rather than inlined as string literals.
14+ const (
15+ RoleUser = "user"
16+ RoleAssistant = "assistant"
17+
18+ BlockText = "text"
19+ BlockThinking = "thinking"
20+ BlockRedactedThinking = "redacted_thinking"
21+ BlockToolUse = "tool_use"
22+ BlockToolResult = "tool_result"
23+ )
24+
25+ // Stop reasons the loop has to distinguish.
26+ const (
27+ StopEndTurn = "end_turn"
28+ StopMaxTokens = "max_tokens"
29+ StopToolUse = "tool_use"
30+ StopPauseTurn = "pause_turn"
31+ StopRefusal = "refusal"
32+ )
33+
34+ // Message is one turn of conversation.
35+ type Message struct {
36+ Role string `json:"role"`
37+ Content []ContentBlock `json:"content"`
38+ }
39+
40+ // ContentBlock is the flattened union of every block type the harness handles.
41+ // Only the fields belonging to Type are serialized, so blocks survive the
42+ // round-trip back to the API — which matters most for thinking blocks, whose
43+ // signature must be echoed back unchanged.
44+ type ContentBlock struct {
45+ Type string `json:"type"`
46+
47+ // text
48+ Text string `json:"text,omitempty"`
49+
50+ // thinking / redacted_thinking. Thinking is deliberately not omitempty:
51+ // with display omitted (the default on Opus 5) the text is empty but the
52+ // field must still be sent back.
53+ Thinking string `json:"thinking"`
54+ Signature string `json:"signature,omitempty"`
55+ Data string `json:"data,omitempty"`
56+
57+ // tool_use
58+ ID string `json:"id,omitempty"`
59+ Name string `json:"name,omitempty"`
60+ Input json.RawMessage `json:"input,omitempty"`
61+
62+ // tool_result
63+ ToolUseID string `json:"tool_use_id,omitempty"`
64+ Content string `json:"content,omitempty"`
65+ IsError bool `json:"is_error,omitempty"`
66+
67+ // raw holds the original bytes of a block type this struct does not model,
68+ // so an unfamiliar block still round-trips instead of being flattened away.
69+ raw json.RawMessage
70+ }
71+
72+ func TextBlock(s string) ContentBlock { return ContentBlock{Type: BlockText, Text: s} }
73+
74+ // ToolResultBlock builds the reply to one tool_use. Every tool_use id must get
75+ // one of these, including failures — a missing result is an API error.
76+ func ToolResultBlock(toolUseID, content string, isError bool) ContentBlock {
77+ if content == "" {
78+ // An empty content field is rejected; say something instead.
79+ content = "(no output)"
80+ }
81+ return ContentBlock{Type: BlockToolResult, ToolUseID: toolUseID, Content: content, IsError: isError}
82+ }
83+
84+ func (b *ContentBlock) UnmarshalJSON(data []byte) error {
85+ type alias ContentBlock
86+ var a alias
87+ if err := json.Unmarshal(data, &a); err != nil {
88+ return err
89+ }
90+ *b = ContentBlock(a)
91+ b.raw = append(json.RawMessage(nil), data...)
92+ return nil
93+ }
94+
95+ // MarshalJSON emits exactly the fields that belong to Type. Marshaling the
96+ // whole struct would send stray keys (an empty "thinking" on every text block)
97+ // and the API rejects some of those.
98+ func (b ContentBlock) MarshalJSON() ([]byte, error) {
99+ m := map[string]any{"type": b.Type}
100+ switch b.Type {
101+ case BlockText:
102+ m["text"] = b.Text
103+ case BlockThinking:
104+ m["thinking"] = b.Thinking
105+ if b.Signature != "" {
106+ m["signature"] = b.Signature
107+ }
108+ case BlockRedactedThinking:
109+ m["data"] = b.Data
110+ case BlockToolUse:
111+ m["id"] = b.ID
112+ m["name"] = b.Name
113+ if len(b.Input) == 0 {
114+ m["input"] = json.RawMessage("{}")
115+ } else {
116+ m["input"] = b.Input
117+ }
118+ case BlockToolResult:
119+ m["tool_use_id"] = b.ToolUseID
120+ m["content"] = b.Content
121+ if b.IsError {
122+ m["is_error"] = true
123+ }
124+ default:
125+ if len(b.raw) > 0 {
126+ return b.raw, nil
127+ }
128+ return nil, fmt.Errorf("llm: cannot marshal unknown content block %q", b.Type)
129+ }
130+ return json.Marshal(m)
131+ }
132+
133+ // SystemBlock is a system-prompt segment. It is a list rather than a string so
134+ // a cache breakpoint can be attached in Phase 4.
135+ type SystemBlock struct {
136+ Type string `json:"type"`
137+ Text string `json:"text"`
138+ CacheControl *CacheControl `json:"cache_control,omitempty"`
139+ }
140+
141+ type CacheControl struct {
142+ Type string `json:"type"`
143+ TTL string `json:"ttl,omitempty"`
144+ }
145+
146+ func Ephemeral() *CacheControl { return &CacheControl{Type: "ephemeral"} }
147+
148+ // ToolDef is one entry of the request's tools array.
149+ type ToolDef struct {
150+ Name string `json:"name"`
151+ Description string `json:"description"`
152+ InputSchema json.RawMessage `json:"input_schema"`
153+ }
154+
155+ // Thinking configures extended thinking. Adaptive is the only mode current
156+ // models accept; budget_tokens is a 400 on Opus 5.
157+ type Thinking struct {
158+ Type string `json:"type"`
159+ Display string `json:"display,omitempty"`
160+ }
161+
162+ func Adaptive() *Thinking { return &Thinking{Type: "adaptive"} }
163+
164+ // OutputConfig carries the effort knob. Omitted means the default, "high".
165+ type OutputConfig struct {
166+ Effort string `json:"effort,omitempty"`
167+ }
168+
169+ // Request is the body of POST /v1/messages.
170+ type Request struct {
171+ Model string `json:"model"`
172+ MaxTokens int `json:"max_tokens"`
173+ System []SystemBlock `json:"system,omitempty"`
174+ Messages []Message `json:"messages"`
175+ Tools []ToolDef `json:"tools,omitempty"`
176+ Thinking *Thinking `json:"thinking,omitempty"`
177+ OutputConfig *OutputConfig `json:"output_config,omitempty"`
178+ Stream bool `json:"stream,omitempty"`
179+ }
180+
181+ // Usage is the token accounting returned with every response.
182+ type Usage struct {
183+ InputTokens int `json:"input_tokens"`
184+ OutputTokens int `json:"output_tokens"`
185+ CacheReadInputTokens int `json:"cache_read_input_tokens"`
186+ CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
187+ }
188+
189+ // StopDetails is populated only when StopReason is "refusal".
190+ type StopDetails struct {
191+ Type string `json:"type"`
192+ Category string `json:"category"`
193+ Explanation string `json:"explanation"`
194+ }
195+
196+ // Response is one assistant turn.
197+ type Response struct {
198+ ID string `json:"id"`
199+ Type string `json:"type"`
200+ Role string `json:"role"`
201+ Model string `json:"model"`
202+ Content []ContentBlock `json:"content"`
203+ StopReason string `json:"stop_reason"`
204+ StopDetails *StopDetails `json:"stop_details"`
205+ Usage Usage `json:"usage"`
206+ }
207+
208+ // Message converts the response into a history entry.
209+ func (r *Response) Message() Message {
210+ return Message{Role: RoleAssistant, Content: r.Content}
211+ }
212+
213+ // ToolUses returns the tool calls in this response, in order.
214+ func (r *Response) ToolUses() []ContentBlock {
215+ var out []ContentBlock
216+ for _, b := range r.Content {
217+ if b.Type == BlockToolUse {
218+ out = append(out, b)
219+ }
220+ }
221+ return out
222+ }
223+
224+ // Text concatenates the response's text blocks.
225+ func (r *Response) Text() string {
226+ var s string
227+ for _, b := range r.Content {
228+ if b.Type == BlockText {
229+ s += b.Text
230+ }
231+ }
232+ return s
233+ }
+124−0internal/llm/types_test.goaddedView File
@@ -0,0 +1,124 @@
1+ package llm
2+
3+ import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+ )
8+
9+ func TestContentBlockMarshalsOnlyItsOwnFields(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ block ContentBlock
13+ want string
14+ }{
15+ {
16+ name: "text carries no stray thinking key",
17+ block: TextBlock("hello"),
18+ want: `{"text":"hello","type":"text"}`,
19+ },
20+ {
21+ name: "tool_use keeps input as raw JSON",
22+ block: ContentBlock{Type: BlockToolUse, ID: "tu_1", Name: "read_file", Input: json.RawMessage(`{"path":"main.go"}`)},
23+ want: `{"id":"tu_1","input":{"path":"main.go"},"name":"read_file","type":"tool_use"}`,
24+ },
25+ {
26+ name: "tool_use with no input sends an empty object",
27+ block: ContentBlock{Type: BlockToolUse, ID: "tu_2", Name: "pwd"},
28+ want: `{"id":"tu_2","input":{},"name":"pwd","type":"tool_use"}`,
29+ },
30+ {
31+ name: "successful tool_result omits is_error",
32+ block: ToolResultBlock("tu_1", "ok", false),
33+ want: `{"content":"ok","tool_use_id":"tu_1","type":"tool_result"}`,
34+ },
35+ {
36+ name: "failed tool_result sets is_error",
37+ block: ToolResultBlock("tu_1", "no such file", true),
38+ want: `{"content":"no such file","is_error":true,"tool_use_id":"tu_1","type":"tool_result"}`,
39+ },
40+ }
41+ for _, tc := range tests {
42+ t.Run(tc.name, func(t *testing.T) {
43+ got, err := json.Marshal(tc.block)
44+ if err != nil {
45+ t.Fatalf("marshal: %v", err)
46+ }
47+ if string(got) != tc.want {
48+ t.Errorf("got %s\nwant %s", got, tc.want)
49+ }
50+ })
51+ }
52+ }
53+
54+ // With display omitted (the default on Opus 5) a thinking block has empty text
55+ // but a signature that must go back unchanged, so the field cannot be omitempty.
56+ func TestThinkingBlockRoundTripsEmptyTextAndSignature(t *testing.T) {
57+ const wire = `{"type":"thinking","thinking":"","signature":"sig-abc123"}`
58+ var b ContentBlock
59+ if err := json.Unmarshal([]byte(wire), &b); err != nil {
60+ t.Fatalf("unmarshal: %v", err)
61+ }
62+ out, err := json.Marshal(b)
63+ if err != nil {
64+ t.Fatalf("marshal: %v", err)
65+ }
66+ if !strings.Contains(string(out), `"thinking":""`) {
67+ t.Errorf("empty thinking text must survive the round trip, got %s", out)
68+ }
69+ if !strings.Contains(string(out), `"signature":"sig-abc123"`) {
70+ t.Errorf("signature must survive the round trip, got %s", out)
71+ }
72+ }
73+
74+ func TestUnknownBlockTypeRoundTripsVerbatim(t *testing.T) {
75+ const wire = `{"type":"server_tool_use_future","id":"x","weird":{"nested":[1,2]}}`
76+ var b ContentBlock
77+ if err := json.Unmarshal([]byte(wire), &b); err != nil {
78+ t.Fatalf("unmarshal: %v", err)
79+ }
80+ out, err := json.Marshal(b)
81+ if err != nil {
82+ t.Fatalf("marshal: %v", err)
83+ }
84+ if string(out) != wire {
85+ t.Errorf("unmodelled block should be echoed verbatim\ngot %s\nwant %s", out, wire)
86+ }
87+ }
88+
89+ func TestEmptyToolResultGetsPlaceholder(t *testing.T) {
90+ if got := ToolResultBlock("tu_1", "", false).Content; got != "(no output)" {
91+ t.Errorf("content = %q, want a placeholder: the API rejects empty tool_result content", got)
92+ }
93+ }
94+
95+ func TestResponseAccessors(t *testing.T) {
96+ const wire = `{
97+ "id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5",
98+ "content":[
99+ {"type":"thinking","thinking":"","signature":"s"},
100+ {"type":"text","text":"Let me look. "},
101+ {"type":"tool_use","id":"tu_1","name":"read_file","input":{"path":"a.go"}},
102+ {"type":"text","text":"One moment."}
103+ ],
104+ "stop_reason":"tool_use","stop_details":null,
105+ "usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":5,"cache_creation_input_tokens":1}
106+ }`
107+ var r Response
108+ if err := json.Unmarshal([]byte(wire), &r); err != nil {
109+ t.Fatalf("unmarshal: %v", err)
110+ }
111+ if got := r.Text(); got != "Let me look. One moment." {
112+ t.Errorf("Text() = %q, want the text blocks concatenated", got)
113+ }
114+ uses := r.ToolUses()
115+ if len(uses) != 1 || uses[0].ID != "tu_1" || uses[0].Name != "read_file" {
116+ t.Errorf("ToolUses() = %+v, want the single read_file call", uses)
117+ }
118+ if r.Usage.CacheReadInputTokens != 5 {
119+ t.Errorf("cache_read_input_tokens = %d, want 5", r.Usage.CacheReadInputTokens)
120+ }
121+ if msg := r.Message(); msg.Role != RoleAssistant || len(msg.Content) != 4 {
122+ t.Errorf("Message() = %+v, want all 4 blocks under the assistant role", msg)
123+ }
124+ }
+113−0internal/tools/read.goaddedView File
@@ -0,0 +1,113 @@
1+ package tools
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+ )
11+
12+ // defaultReadLimit is how many lines read_file returns when the model does not
13+ // ask for a window.
14+ const defaultReadLimit = 2000
15+
16+ // ReadTracker records that a file was read, so edit_file (Phase 2) can refuse
17+ // to edit a file the model has not looked at. agent.State implements it.
18+ type ReadTracker interface {
19+ MarkRead(path string, mod time.Time)
20+ }
21+
22+ // ReadArgs is the read_file input.
23+ type ReadArgs struct {
24+ Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"`
25+ Offset int `json:"offset,omitempty" jsonschema:"description=1-indexed line to start at; omit to start at the beginning"`
26+ Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of lines to return; defaults to 2000"`
27+ }
28+
29+ // NewRead builds the read_file tool. root anchors relative paths; track may be nil.
30+ func NewRead(root string, track ReadTracker) Tool {
31+ return New("read_file", strings.TrimSpace(`
32+ Read a file from disk. Returns the contents with 1-indexed line numbers, in the
33+ form " 12\tcode". Use offset and limit to page through a large file rather
34+ than reading it whole. Line numbers are for your reference only — never include
35+ them in text you write back to the file.`),
36+ func(_ context.Context, in ReadArgs) (string, error) {
37+ path, err := resolve(root, in.Path)
38+ if err != nil {
39+ return "", err
40+ }
41+ info, err := os.Stat(path)
42+ switch {
43+ case os.IsNotExist(err):
44+ return "", fmt.Errorf("no such file: %s", in.Path)
45+ case err != nil:
46+ return "", err
47+ case info.IsDir():
48+ return "", fmt.Errorf("%s is a directory, not a file; use glob or grep to look inside it", in.Path)
49+ }
50+
51+ data, err := os.ReadFile(path)
52+ if err != nil {
53+ return "", err
54+ }
55+ if track != nil {
56+ track.MarkRead(path, info.ModTime())
57+ }
58+ if len(data) == 0 {
59+ return fmt.Sprintf("%s is empty (0 bytes).", in.Path), nil
60+ }
61+
62+ lines := splitLines(string(data))
63+ start := in.Offset
64+ if start < 1 {
65+ start = 1
66+ }
67+ if start > len(lines) {
68+ return "", fmt.Errorf("offset %d is past the end of %s, which has %d lines", in.Offset, in.Path, len(lines))
69+ }
70+ limit := in.Limit
71+ if limit <= 0 {
72+ limit = defaultReadLimit
73+ }
74+ end := start + limit - 1
75+ if end > len(lines) {
76+ end = len(lines)
77+ }
78+
79+ var b strings.Builder
80+ for i := start; i <= end; i++ {
81+ fmt.Fprintf(&b, "%6d\t%s\n", i, capLine(lines[i-1]))
82+ }
83+ if end < len(lines) {
84+ fmt.Fprintf(&b, "\n(showing lines %d-%d of %d; call read_file again with offset=%d for more)", start, end, len(lines), end+1)
85+ }
86+ return b.String(), nil
87+ })
88+ }
89+
90+ // splitLines splits on \n and drops the trailing empty element a final newline
91+ // produces, so a 3-line file reports 3 lines.
92+ func splitLines(s string) []string {
93+ s = strings.ReplaceAll(s, "\r\n", "\n")
94+ lines := strings.Split(s, "\n")
95+ if n := len(lines); n > 0 && lines[n-1] == "" {
96+ lines = lines[:n-1]
97+ }
98+ return lines
99+ }
100+
101+ // resolve anchors a relative path to root. The real path jail lands in Phase 6.
102+ func resolve(root, path string) (string, error) {
103+ if path == "" {
104+ return "", fmt.Errorf("path is required")
105+ }
106+ if filepath.IsAbs(path) {
107+ return filepath.Clean(path), nil
108+ }
109+ if root == "" {
110+ root = "."
111+ }
112+ return filepath.Join(root, path), nil
113+ }
+179−0internal/tools/read_test.goaddedView File
@@ -0,0 +1,179 @@
1+ package tools
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+ )
13+
14+ type fakeTracker struct {
15+ path string
16+ mod time.Time
17+ n int
18+ }
19+
20+ func (f *fakeTracker) MarkRead(path string, mod time.Time) {
21+ f.path, f.mod, f.n = path, mod, f.n+1
22+ }
23+
24+ func writeFile(t *testing.T, dir, name, content string) string {
25+ t.Helper()
26+ p := filepath.Join(dir, name)
27+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
31+ t.Fatal(err)
32+ }
33+ return p
34+ }
35+
36+ func readCall(t *testing.T, tool Tool, args any) (string, error) {
37+ t.Helper()
38+ raw, err := json.Marshal(args)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ return tool.Execute(context.Background(), raw)
43+ }
44+
45+ func TestReadFileNumbersLines(t *testing.T) {
46+ dir := t.TempDir()
47+ writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n")
48+ out, err := readCall(t, NewRead(dir, nil), ReadArgs{Path: "main.go"})
49+ if err != nil {
50+ t.Fatalf("read: %v", err)
51+ }
52+ want := " 1\tpackage main\n 2\t\n 3\tfunc main() {}\n"
53+ if out != want {
54+ t.Errorf("got:\n%q\nwant:\n%q", out, want)
55+ }
56+ }
57+
58+ func TestReadFileOffsetAndLimit(t *testing.T) {
59+ dir := t.TempDir()
60+ var sb strings.Builder
61+ for i := 1; i <= 10; i++ {
62+ fmt.Fprintf(&sb, "line %d\n", i)
63+ }
64+ writeFile(t, dir, "ten.txt", sb.String())
65+ tool := NewRead(dir, nil)
66+
67+ out, err := readCall(t, tool, ReadArgs{Path: "ten.txt", Offset: 4, Limit: 2})
68+ if err != nil {
69+ t.Fatalf("read: %v", err)
70+ }
71+ if !strings.Contains(out, " 4\tline 4") || !strings.Contains(out, " 5\tline 5") {
72+ t.Errorf("window should be lines 4-5, got:\n%s", out)
73+ }
74+ if strings.Contains(out, "line 6") {
75+ t.Errorf("limit not respected:\n%s", out)
76+ }
77+ if !strings.Contains(out, "offset=6") {
78+ t.Errorf("a partial read should tell the model how to continue, got:\n%s", out)
79+ }
80+ }
81+
82+ func TestReadFileErrorsCarryEnoughToRecover(t *testing.T) {
83+ dir := t.TempDir()
84+ writeFile(t, dir, "sub/a.go", "x\n")
85+ tool := NewRead(dir, nil)
86+
87+ tests := []struct {
88+ name string
89+ args ReadArgs
90+ want string
91+ }{
92+ {name: "missing file names the path", args: ReadArgs{Path: "nope.go"}, want: "no such file: nope.go"},
93+ {name: "directory suggests another tool", args: ReadArgs{Path: "sub"}, want: "is a directory"},
94+ {name: "offset past EOF reports the real length", args: ReadArgs{Path: "sub/a.go", Offset: 99}, want: "which has 1 lines"},
95+ {name: "empty path", args: ReadArgs{}, want: "path is required"},
96+ }
97+ for _, tc := range tests {
98+ t.Run(tc.name, func(t *testing.T) {
99+ _, err := readCall(t, tool, tc.args)
100+ if err == nil {
101+ t.Fatal("want an error")
102+ }
103+ if !strings.Contains(err.Error(), tc.want) {
104+ t.Errorf("error = %q, want it to contain %q", err, tc.want)
105+ }
106+ })
107+ }
108+ }
109+
110+ func TestReadFileMarksTheReadWithFileMtime(t *testing.T) {
111+ dir := t.TempDir()
112+ path := writeFile(t, dir, "tracked.go", "a\n")
113+ info, err := os.Stat(path)
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ tr := &fakeTracker{}
118+ if _, err := readCall(t, NewRead(dir, tr), ReadArgs{Path: "tracked.go"}); err != nil {
119+ t.Fatalf("read: %v", err)
120+ }
121+ if tr.n != 1 {
122+ t.Fatalf("MarkRead called %d times, want 1", tr.n)
123+ }
124+ if tr.path != path {
125+ t.Errorf("tracked path = %q, want the resolved path %q", tr.path, path)
126+ }
127+ if !tr.mod.Equal(info.ModTime()) {
128+ t.Errorf("tracked mtime = %v, want the file's %v", tr.mod, info.ModTime())
129+ }
130+ }
131+
132+ func TestReadFileDoesNotTrackFailedReads(t *testing.T) {
133+ tr := &fakeTracker{}
134+ if _, err := readCall(t, NewRead(t.TempDir(), tr), ReadArgs{Path: "ghost.go"}); err == nil {
135+ t.Fatal("want an error")
136+ }
137+ if tr.n != 0 {
138+ t.Error("a failed read must not count as having read the file")
139+ }
140+ }
141+
142+ func TestReadFileEmptyAndAbsolutePaths(t *testing.T) {
143+ dir := t.TempDir()
144+ abs := writeFile(t, dir, "empty.txt", "")
145+ out, err := readCall(t, NewRead("/nonexistent-root", nil), ReadArgs{Path: abs})
146+ if err != nil {
147+ t.Fatalf("absolute paths should bypass the root: %v", err)
148+ }
149+ if !strings.Contains(out, "is empty") {
150+ t.Errorf("out = %q, want an explicit empty-file note rather than a blank result", out)
151+ }
152+ }
153+
154+ func TestReadFileCapsRunawayLines(t *testing.T) {
155+ dir := t.TempDir()
156+ writeFile(t, dir, "min.js", strings.Repeat("x", maxLineRunes+500)+"\n")
157+ out, err := readCall(t, NewRead(dir, nil), ReadArgs{Path: "min.js"})
158+ if err != nil {
159+ t.Fatalf("read: %v", err)
160+ }
161+ if !strings.Contains(out, "500 more characters on this line") {
162+ t.Errorf("long line should be capped with a count, got %d bytes", len(out))
163+ }
164+ }
165+
166+ func TestHeadTailKeepsTheEnd(t *testing.T) {
167+ var lines []string
168+ for i := 1; i <= 50; i++ {
169+ lines = append(lines, fmt.Sprintf("line %d", i))
170+ }
171+ got := headTail(strings.Join(lines, "\n"), 2, 2)
172+ want := "line 1\nline 2\n… 46 lines elided …\nline 49\nline 50"
173+ if got != want {
174+ t.Errorf("got:\n%s\nwant:\n%s", got, want)
175+ }
176+ if short := headTail("a\nb\nc", 2, 2); short != "a\nb\nc" {
177+ t.Errorf("short input should pass through unchanged, got %q", short)
178+ }
179+ }
+35−0internal/tools/truncate.goaddedView File
@@ -0,0 +1,35 @@
1+ package tools
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ )
7+
8+ // maxLineRunes caps a single line so one minified bundle cannot flood the
9+ // context window.
10+ const maxLineRunes = 2000
11+
12+ // capLine shortens an over-long line, saying by how much.
13+ func capLine(s string) string {
14+ r := []rune(s)
15+ if len(r) <= maxLineRunes {
16+ return s
17+ }
18+ return fmt.Sprintf("%s… (%d more characters on this line)", string(r[:maxLineRunes]), len(r)-maxLineRunes)
19+ }
20+
21+ // headTail caps output by keeping the first and last n lines. Head-only
22+ // truncation loses the end of the output, which for a test run is exactly the
23+ // part that matters.
24+ func headTail(s string, head, tail int) string {
25+ lines := strings.Split(s, "\n")
26+ if len(lines) <= head+tail {
27+ return s
28+ }
29+ elided := len(lines) - head - tail
30+ kept := make([]string, 0, head+tail+1)
31+ kept = append(kept, lines[:head]...)
32+ kept = append(kept, fmt.Sprintf("… %d lines elided …", elided))
33+ kept = append(kept, lines[len(lines)-tail:]...)
34+ return strings.Join(kept, "\n")
35+ }
+109−0internal/ui/render.goaddedView File
@@ -0,0 +1,109 @@
1+ // Package ui renders agent events to a terminal. The dependency runs one way:
2+ // ui imports agent, never the reverse, so the eval runner can drive the loop
3+ // with no terminal attached.
4+ package ui
5+
6+ import (
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "os"
11+ "sort"
12+ "strings"
13+
14+ "harness/internal/agent"
15+ "harness/internal/llm"
16+ )
17+
18+ // ANSI codes, blanked when the output is not a terminal or NO_COLOR is set.
19+ type palette struct{ dim, bold, red, cyan, reset string }
20+
21+ var color = func() palette {
22+ info, err := os.Stdout.Stat()
23+ interactive := err == nil && info.Mode()&os.ModeCharDevice != 0
24+ if !interactive || os.Getenv("NO_COLOR") != "" {
25+ return palette{}
26+ }
27+ return palette{dim: "\x1b[2m", bold: "\x1b[1m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m"}
28+ }()
29+
30+ // Renderer prints the event stream.
31+ type Renderer struct {
32+ W io.Writer
33+ Verbose bool // print full tool results rather than a summary line
34+ }
35+
36+ func New(w io.Writer) *Renderer { return &Renderer{W: w} }
37+
38+ // Handle is the agent.Observe callback.
39+ func (r *Renderer) Handle(ev agent.Event) {
40+ switch ev.Kind {
41+ case agent.EventText:
42+ fmt.Fprintf(r.W, "%s\n", strings.TrimSpace(ev.Text))
43+ case agent.EventToolStart:
44+ fmt.Fprintf(r.W, "%s⏺ %s%s%s(%s)%s\n", color.cyan, color.bold, ev.Tool, color.reset, summarizeInput(ev.Input), color.reset)
45+ case agent.EventToolResult:
46+ r.tool(ev)
47+ }
48+ }
49+
50+ func (r *Renderer) tool(ev agent.Event) {
51+ prefix, c := " ⎿ ", color.dim
52+ if ev.IsError {
53+ prefix, c = " ⎿ error: ", color.red
54+ }
55+ body := strings.TrimRight(ev.Result, "\n")
56+ if !r.Verbose {
57+ body = firstLines(body, 4)
58+ }
59+ for i, line := range strings.Split(body, "\n") {
60+ if i == 0 {
61+ fmt.Fprintf(r.W, "%s%s%s%s\n", c, prefix, line, color.reset)
62+ continue
63+ }
64+ fmt.Fprintf(r.W, "%s %s%s\n", c, line, color.reset)
65+ }
66+ }
67+
68+ // summarizeInput renders tool arguments as k=v, sorted so the same call always
69+ // prints the same way.
70+ func summarizeInput(raw json.RawMessage) string {
71+ var m map[string]any
72+ if len(raw) == 0 || json.Unmarshal(raw, &m) != nil {
73+ return string(raw)
74+ }
75+ keys := make([]string, 0, len(m))
76+ for k := range m {
77+ keys = append(keys, k)
78+ }
79+ sort.Strings(keys)
80+
81+ parts := make([]string, 0, len(keys))
82+ for _, k := range keys {
83+ v := fmt.Sprintf("%v", m[k])
84+ if len(v) > 60 {
85+ v = v[:60] + "…"
86+ }
87+ parts = append(parts, fmt.Sprintf("%s=%s", k, v))
88+ }
89+ return strings.Join(parts, ", ")
90+ }
91+
92+ func firstLines(s string, n int) string {
93+ lines := strings.Split(s, "\n")
94+ if len(lines) <= n {
95+ return s
96+ }
97+ return strings.Join(lines[:n], "\n") + fmt.Sprintf("\n… %d more lines", len(lines)-n)
98+ }
99+
100+ // Usage prints the running token totals for the session.
101+ func (r *Renderer) Usage(u llm.Usage, turns int) {
102+ fmt.Fprintf(r.W, "%s%d turns · in %d (%d cached) · out %d%s\n",
103+ color.dim, turns, u.InputTokens, u.CacheReadInputTokens, u.OutputTokens, color.reset)
104+ }
105+
106+ // Error prints a failure.
107+ func (r *Renderer) Error(err error) {
108+ fmt.Fprintf(r.W, "%serror: %v%s\n", color.red, err, color.reset)
109+ }
+63−0internal/ui/render_test.goaddedView File
@@ -0,0 +1,63 @@
1+ package ui
2+
3+ import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "harness/internal/agent"
10+ "harness/internal/llm"
11+ )
12+
13+ func TestSummarizeInputIsSortedAndBounded(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ in string
17+ want string
18+ }{
19+ {name: "sorted keys", in: `{"path":"main.go","offset":10}`, want: "offset=10, path=main.go"},
20+ {name: "empty input", in: `{}`, want: ""},
21+ {name: "unparseable falls back to raw", in: `not json`, want: "not json"},
22+ {name: "long value truncated", in: `{"cmd":"` + strings.Repeat("x", 80) + `"}`, want: "cmd=" + strings.Repeat("x", 60) + "…"},
23+ }
24+ for _, tc := range tests {
25+ t.Run(tc.name, func(t *testing.T) {
26+ if got := summarizeInput(json.RawMessage(tc.in)); got != tc.want {
27+ t.Errorf("got %q, want %q", got, tc.want)
28+ }
29+ })
30+ }
31+ }
32+
33+ func TestToolResultIsSummarizedUnlessVerbose(t *testing.T) {
34+ long := strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")
35+ ev := agent.Event{Kind: agent.EventToolResult, Tool: "read_file", Result: long}
36+
37+ var quiet, loud bytes.Buffer
38+ New(&quiet).Handle(ev)
39+ (&Renderer{W: &loud, Verbose: true}).Handle(ev)
40+
41+ if !strings.Contains(quiet.String(), "2 more lines") {
42+ t.Errorf("default should summarize, got:\n%s", quiet.String())
43+ }
44+ if strings.Contains(loud.String(), "more lines") || !strings.Contains(loud.String(), "6") {
45+ t.Errorf("verbose should print everything, got:\n%s", loud.String())
46+ }
47+ }
48+
49+ func TestErrorResultIsLabelled(t *testing.T) {
50+ var buf bytes.Buffer
51+ New(&buf).Handle(agent.Event{Kind: agent.EventToolResult, Result: "no such file", IsError: true})
52+ if !strings.Contains(buf.String(), "error: no such file") {
53+ t.Errorf("got %q, want the failure marked", buf.String())
54+ }
55+ }
56+
57+ func TestUsageLine(t *testing.T) {
58+ var buf bytes.Buffer
59+ New(&buf).Usage(llm.Usage{InputTokens: 1200, OutputTokens: 300, CacheReadInputTokens: 900}, 4)
60+ if got := buf.String(); !strings.Contains(got, "4 turns") || !strings.Contains(got, "900 cached") {
61+ t.Errorf("got %q, want turns and cache reads", got)
62+ }
63+ }