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>
Showing 13 changed files with 2028 additions and 13 deletions.
| @@ -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. | |
| 3 | 2 | package main |
| 4 | 3 | |
| 5 | 4 | import ( |
| 6 | - "encoding/json" | |
| 5 | + "bufio" | |
| 6 | + "context" | |
| 7 | 7 | "flag" |
| 8 | 8 | "fmt" |
| 9 | 9 | "os" |
| 10 | 10 | "path/filepath" |
| 11 | + "strings" | |
| 11 | 12 | "time" |
| 12 | 13 | |
| 14 | + "harness/internal/agent" | |
| 15 | + "harness/internal/llm" | |
| 13 | 16 | "harness/internal/metrics" |
| 14 | 17 | "harness/internal/tools" |
| 18 | + "harness/internal/ui" | |
| 15 | 19 | ) |
| 16 | 20 | |
| 17 | 21 | func main() { |
| @@ -23,7 +27,12 @@ func main() { | ||
| 23 | 27 | |
| 24 | 28 | func run() error { |
| 25 | 29 | 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") | |
| 27 | 36 | flag.Parse() |
| 28 | 37 | |
| 29 | 38 | root, err := filepath.Abs(*workdir) |
| @@ -37,17 +46,64 @@ func run() error { | ||
| 37 | 46 | return err |
| 38 | 47 | } |
| 39 | 48 | 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}}) | |
| 41 | 50 | |
| 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 | + } | |
| 44 | 102 | |
| 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 | |
| 47 | 105 | 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()) | |
| 50 | 107 | } |
| 51 | - fmt.Println("\nno agent loop yet — Phase 1.") | |
| 52 | - return nil | |
| 108 | + return names | |
| 53 | 109 | } |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |