Two cache breakpoints, well short of the limit of four: one on the system block, which also covers the tool definitions since tools render before system, and one rolling forward over the end of the conversation so each turn writes a small delta and reads a large prefix. The breakpoints are applied to a copy — leaving them in the stored history would accumulate one per turn and blow the limit — and -no-cache turns them off so the eval suite can measure what they are worth. Compaction triggers on the input token count the API reported last turn, which is exact and free, rather than on a local estimate. The cut always lands on an assistant message: the summary takes the user slot before it, and a tail starting with a tool_result message would reference a tool_use that no longer exists. The original task is carried through verbatim instead of being summarized, and the summarization call is made without tools so it cannot touch the repository. Stale reads are the subtler win. A file read twenty turns ago and edited since is not just wasted context, it is wrong, and the model will reason from it. After a successful edit_file or write_file, earlier read results for that path are replaced with a stub pointing at the current version. The most recent read survives, since that one is still true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 9 changed files with 622 additions and 10 deletions.
| @@ -34,6 +34,9 @@ func run() error { | ||
| 34 | 34 | authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)") |
| 35 | 35 | effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)") |
| 36 | 36 | maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt") |
| 37 | + contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact") | |
| 38 | + compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact") | |
| 39 | + noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints") | |
| 37 | 40 | logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root") |
| 38 | 41 | prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL") |
| 39 | 42 | verbose := flag.Bool("v", false, "print full tool output") |
| @@ -82,7 +85,13 @@ func run() error { | ||
| 82 | 85 | ) |
| 83 | 86 | |
| 84 | 87 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 85 | - a := agent.New(client, reg, state, log, agent.Options{MaxTurns: *maxTurns, Effort: *effort}) | |
| 88 | + a := agent.New(client, reg, state, log, agent.Options{ | |
| 89 | + MaxTurns: *maxTurns, | |
| 90 | + Effort: *effort, | |
| 91 | + ContextWindow: *contextWindow, | |
| 92 | + CompactAt: *compactAt, | |
| 93 | + NoCache: *noCache, | |
| 94 | + }) | |
| 86 | 95 | a.Observe = render.Handle |
| 87 | 96 | |
| 88 | 97 | ctx := context.Background() |
| @@ -69,6 +69,8 @@ func run() error { | ||
| 69 | 69 | model := flag.String("model", "", "model id") |
| 70 | 70 | effort := flag.String("effort", "", "output_config.effort") |
| 71 | 71 | maxTurns := flag.Int("max-turns", 40, "turn cap per run") |
| 72 | + contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window") | |
| 73 | + noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints, to measure what they buy") | |
| 72 | 74 | timeout := flag.Duration("timeout", 10*time.Minute, "wall-clock cap per run") |
| 73 | 75 | tasksDir := flag.String("dir", "evals/tasks", "directory of task definitions") |
| 74 | 76 | outDir := flag.String("out", "evals/runs", "where to persist transcripts") |
| @@ -118,7 +120,12 @@ func run() error { | ||
| 118 | 120 | sem <- struct{}{} |
| 119 | 121 | defer func() { <-sem }() |
| 120 | 122 | |
| 121 | - res := runOne(j.task, j.run, client, *maxTurns, *effort, *timeout, *keep) | |
| 123 | + res := runOne(j.task, j.run, client, agent.Options{ | |
| 124 | + MaxTurns: *maxTurns, | |
| 125 | + Effort: *effort, | |
| 126 | + ContextWindow: *contextWindow, | |
| 127 | + NoCache: *noCache, | |
| 128 | + }, *timeout, *keep) | |
| 122 | 129 | mu.Lock() |
| 123 | 130 | results = append(results, res) |
| 124 | 131 | mu.Unlock() |
| @@ -151,7 +158,7 @@ func run() error { | ||
| 151 | 158 | // verifies the result. |
| 152 | 159 | // The return value is named so the deferred timing assignment lands in the |
| 153 | 160 | // value actually returned rather than in a copy already made. |
| 154 | - func runOne(task Task, runNo int, client *llm.Client, maxTurns int, effort string, timeout time.Duration, keep bool) (res Result) { | |
| 161 | + func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) { | |
| 155 | 162 | res = Result{Task: task.Name, Run: runNo} |
| 156 | 163 | start := time.Now() |
| 157 | 164 | defer func() { res.WallMS = time.Since(start).Milliseconds() }() |
| @@ -185,7 +192,7 @@ func runOne(task Task, runNo int, client *llm.Client, maxTurns int, effort strin | ||
| 185 | 192 | tools.NewWrite(work, state), |
| 186 | 193 | tools.NewBash(work), |
| 187 | 194 | ) |
| 188 | - a := agent.New(client, reg, state, log, agent.Options{MaxTurns: maxTurns, Effort: effort}) | |
| 195 | + a := agent.New(client, reg, state, log, opts) | |
| 189 | 196 | |
| 190 | 197 | answer, runErr := a.Run(ctx, task.Prompt) |
| 191 | 198 | res.Answer = answer |
| @@ -0,0 +1,173 @@ | ||
| 1 | + package agent | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "harness/internal/llm" | |
| 9 | + "harness/internal/metrics" | |
| 10 | + ) | |
| 11 | + | |
| 12 | + // metricsCompaction records a compaction so its effect on later turns is | |
| 13 | + // visible in the event log. | |
| 14 | + func metricsCompaction(inputTokens, before, after int) metrics.Event { | |
| 15 | + return metrics.Event{ | |
| 16 | + Type: "compaction", | |
| 17 | + InputTokens: inputTokens, | |
| 18 | + Extra: map[string]any{"messages_before": before, "messages_after": after}, | |
| 19 | + } | |
| 20 | + } | |
| 21 | + | |
| 22 | + // DefaultContextWindow is the Claude Opus 5 window. Lower it with | |
| 23 | + // Options.ContextWindow when running a model with less room. | |
| 24 | + const DefaultContextWindow = 1_000_000 | |
| 25 | + | |
| 26 | + // DefaultCompactAt is the fraction of the window at which to summarize. | |
| 27 | + // Compaction itself needs headroom — it is a model call with the history in it | |
| 28 | + // — so waiting until the window is nearly full means the compaction request is | |
| 29 | + // the one that fails. | |
| 30 | + const DefaultCompactAt = 0.7 | |
| 31 | + | |
| 32 | + // keepRecentMessages is how much of the tail survives compaction verbatim. The | |
| 33 | + // model is usually mid-thought, and a summary of the last two minutes of work | |
| 34 | + // is worse than the work itself. | |
| 35 | + const keepRecentMessages = 6 | |
| 36 | + | |
| 37 | + const compactPrompt = `Summarize the conversation above for a coding agent that will keep working on the same task with this summary in place of the transcript. | |
| 38 | + | |
| 39 | + Preserve, in this order: | |
| 40 | + 1. The original task, verbatim enough that nothing about it is lost. | |
| 41 | + 2. Every file touched so far and what was changed in it, with paths. | |
| 42 | + 3. Decisions made and the reasoning that is not re-derivable from the code. | |
| 43 | + 4. Things discovered the hard way: where something actually lives, what a test really asserts, what did not work and why. | |
| 44 | + 5. What is left to do. | |
| 45 | + | |
| 46 | + Be specific. Names, paths and line numbers are worth more than prose. Omit | |
| 47 | + pleasantries and anything already visible in the current state of the files.` | |
| 48 | + | |
| 49 | + // maybeCompact summarizes the older half of the conversation when the last | |
| 50 | + // request came close to filling the window. It reports whether it compacted. | |
| 51 | + func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) { | |
| 52 | + window := a.opts.ContextWindow | |
| 53 | + if window <= 0 { | |
| 54 | + window = DefaultContextWindow | |
| 55 | + } | |
| 56 | + at := a.opts.CompactAt | |
| 57 | + if at <= 0 { | |
| 58 | + at = DefaultCompactAt | |
| 59 | + } | |
| 60 | + if float64(lastInputTokens) < at*float64(window) { | |
| 61 | + return false, nil | |
| 62 | + } | |
| 63 | + | |
| 64 | + history := a.state.History() | |
| 65 | + split := compactionSplit(history) | |
| 66 | + if split < 0 { | |
| 67 | + return false, nil // nothing safe to fold away yet | |
| 68 | + } | |
| 69 | + | |
| 70 | + summary, err := a.summarize(ctx, history[:split]) | |
| 71 | + if err != nil { | |
| 72 | + return false, err | |
| 73 | + } | |
| 74 | + | |
| 75 | + // The task itself is the one thing never summarized: everything downstream | |
| 76 | + // is judged against it. | |
| 77 | + task := originalTask(history) | |
| 78 | + folded := llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{ | |
| 79 | + llm.TextBlock(fmt.Sprintf("[Earlier conversation, compacted]\n\nOriginal task:\n%s\n\nWhat has happened since:\n%s", task, summary)), | |
| 80 | + }} | |
| 81 | + | |
| 82 | + a.state.Replace(append([]llm.Message{folded}, history[split:]...)) | |
| 83 | + a.log.Log(metricsCompaction(lastInputTokens, len(history), a.state.Len())) | |
| 84 | + a.emit(Event{Kind: EventCompact, Text: summary}) | |
| 85 | + return true, nil | |
| 86 | + } | |
| 87 | + | |
| 88 | + // compactionSplit picks where to cut. The kept tail must begin with an | |
| 89 | + // assistant message: the folded summary takes the user slot before it, and a | |
| 90 | + // tail starting with a tool_result user message would reference a tool_use that | |
| 91 | + // no longer exists, which the API rejects. | |
| 92 | + func compactionSplit(history []llm.Message) int { | |
| 93 | + if len(history) <= keepRecentMessages+2 { | |
| 94 | + return -1 | |
| 95 | + } | |
| 96 | + want := len(history) / 2 | |
| 97 | + if max := len(history) - keepRecentMessages; want > max { | |
| 98 | + want = max | |
| 99 | + } | |
| 100 | + for i := want; i < len(history); i++ { | |
| 101 | + if history[i].Role == llm.RoleAssistant { | |
| 102 | + return i | |
| 103 | + } | |
| 104 | + } | |
| 105 | + return -1 | |
| 106 | + } | |
| 107 | + | |
| 108 | + // summarize asks the model to fold the given messages into prose. It runs | |
| 109 | + // without tools: this call must not be able to touch the repository. | |
| 110 | + func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, error) { | |
| 111 | + msgs := append([]llm.Message(nil), history...) | |
| 112 | + msgs = append(msgs, llm.Message{ | |
| 113 | + Role: llm.RoleUser, | |
| 114 | + Content: []llm.ContentBlock{llm.TextBlock(compactPrompt)}, | |
| 115 | + }) | |
| 116 | + | |
| 117 | + resp, err := a.client.CreateMessage(ctx, llm.Request{ | |
| 118 | + MaxTokens: 4096, | |
| 119 | + Messages: stripTrailingToolUse(msgs), | |
| 120 | + Thinking: llm.Adaptive(), | |
| 121 | + }) | |
| 122 | + if err != nil { | |
| 123 | + return "", fmt.Errorf("compaction: %w", err) | |
| 124 | + } | |
| 125 | + text := strings.TrimSpace(resp.Text()) | |
| 126 | + if text == "" { | |
| 127 | + return "", fmt.Errorf("compaction produced no summary") | |
| 128 | + } | |
| 129 | + return text, nil | |
| 130 | + } | |
| 131 | + | |
| 132 | + // stripTrailingToolUse drops a dangling assistant tool_use at the end, which | |
| 133 | + // would otherwise demand tool_result blocks the summarization request has no | |
| 134 | + // way to supply. | |
| 135 | + func stripTrailingToolUse(msgs []llm.Message) []llm.Message { | |
| 136 | + for len(msgs) > 0 { | |
| 137 | + last := msgs[len(msgs)-1] | |
| 138 | + if last.Role != llm.RoleAssistant { | |
| 139 | + break | |
| 140 | + } | |
| 141 | + var hasToolUse bool | |
| 142 | + for _, b := range last.Content { | |
| 143 | + if b.Type == llm.BlockToolUse { | |
| 144 | + hasToolUse = true | |
| 145 | + break | |
| 146 | + } | |
| 147 | + } | |
| 148 | + if !hasToolUse { | |
| 149 | + break | |
| 150 | + } | |
| 151 | + msgs = msgs[:len(msgs)-1] | |
| 152 | + } | |
| 153 | + return msgs | |
| 154 | + } | |
| 155 | + | |
| 156 | + // originalTask returns the first user message's text. | |
| 157 | + func originalTask(history []llm.Message) string { | |
| 158 | + for _, m := range history { | |
| 159 | + if m.Role != llm.RoleUser { | |
| 160 | + continue | |
| 161 | + } | |
| 162 | + var b strings.Builder | |
| 163 | + for _, blk := range m.Content { | |
| 164 | + if blk.Type == llm.BlockText { | |
| 165 | + b.WriteString(blk.Text) | |
| 166 | + } | |
| 167 | + } | |
| 168 | + if s := strings.TrimSpace(b.String()); s != "" { | |
| 169 | + return s | |
| 170 | + } | |
| 171 | + } | |
| 172 | + return "(unrecorded)" | |
| 173 | + } | |
| @@ -0,0 +1,252 @@ | ||
| 1 | + package agent | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + | |
| 12 | + "harness/internal/llm" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + func TestCacheBreakpoints(t *testing.T) { | |
| 16 | + f := newFakeAPI(t, msg("end_turn", textBlock("hi"))) | |
| 17 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 18 | + if _, err := a.Run(context.Background(), "hello"); err != nil { | |
| 19 | + t.Fatalf("Run: %v", err) | |
| 20 | + } | |
| 21 | + req := f.requests[0] | |
| 22 | + if req.System[0].CacheControl == nil { | |
| 23 | + t.Error("the system prompt should carry a breakpoint: it and the tools never change") | |
| 24 | + } | |
| 25 | + last := req.Messages[len(req.Messages)-1] | |
| 26 | + if last.Content[len(last.Content)-1].CacheControl == nil { | |
| 27 | + t.Error("the end of the conversation should carry a rolling breakpoint") | |
| 28 | + } | |
| 29 | + raw := f.rawRequest(0) | |
| 30 | + if n := strings.Count(raw, `"cache_control"`); n != 2 { | |
| 31 | + t.Errorf("%d breakpoints in the body, want 2 (the limit is 4):\n%s", n, raw) | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + func TestNoCacheOption(t *testing.T) { | |
| 36 | + f := newFakeAPI(t, msg("end_turn", textBlock("hi"))) | |
| 37 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 38 | + a.opts.NoCache = true | |
| 39 | + if _, err := a.Run(context.Background(), "hello"); err != nil { | |
| 40 | + t.Fatalf("Run: %v", err) | |
| 41 | + } | |
| 42 | + if strings.Contains(f.rawRequest(0), "cache_control") { | |
| 43 | + t.Error("NoCache should send no breakpoints") | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + // Breakpoints must not mutate the stored history, or every turn would | |
| 48 | + // accumulate another one and blow past the four-breakpoint limit. | |
| 49 | + func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) { | |
| 50 | + f := newFakeAPI(t, | |
| 51 | + msg("tool_use", toolUseBlock("tu_1", "boom", `{}`)), | |
| 52 | + msg("tool_use", toolUseBlock("tu_2", "boom", `{}`)), | |
| 53 | + msg("end_turn", textBlock("done")), | |
| 54 | + ) | |
| 55 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 56 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 57 | + t.Fatalf("Run: %v", err) | |
| 58 | + } | |
| 59 | + for i := range f.requests { | |
| 60 | + if n := strings.Count(f.rawRequest(i), `"cache_control"`); n != 2 { | |
| 61 | + t.Errorf("request %d has %d breakpoints, want 2", i, n) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + for _, m := range a.State().History() { | |
| 65 | + for _, b := range m.Content { | |
| 66 | + if b.CacheControl != nil { | |
| 67 | + t.Error("stored history should stay free of breakpoints") | |
| 68 | + } | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) { | |
| 74 | + mk := func(roles string) []llm.Message { | |
| 75 | + var out []llm.Message | |
| 76 | + for _, r := range roles { | |
| 77 | + role := llm.RoleUser | |
| 78 | + if r == 'a' { | |
| 79 | + role = llm.RoleAssistant | |
| 80 | + } | |
| 81 | + out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}}) | |
| 82 | + } | |
| 83 | + return out | |
| 84 | + } | |
| 85 | + tests := []struct { | |
| 86 | + name string | |
| 87 | + roles string | |
| 88 | + want int | |
| 89 | + }{ | |
| 90 | + {name: "too short to compact", roles: "uaua", want: -1}, | |
| 91 | + {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", want: 7}, | |
| 92 | + // The tail can end up one message shorter than the target when the | |
| 93 | + // boundary lands on a user message: correctness beats the constant. | |
| 94 | + {name: "skips forward past a user message", roles: "uauauauaua", want: 5}, | |
| 95 | + } | |
| 96 | + for _, tc := range tests { | |
| 97 | + t.Run(tc.name, func(t *testing.T) { | |
| 98 | + got := compactionSplit(mk(tc.roles)) | |
| 99 | + if got != tc.want { | |
| 100 | + t.Fatalf("compactionSplit = %d, want %d", got, tc.want) | |
| 101 | + } | |
| 102 | + if got >= 0 && mk(tc.roles)[got].Role != llm.RoleAssistant { | |
| 103 | + t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing") | |
| 104 | + } | |
| 105 | + }) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + func TestCompactionFoldsHistoryAndKeepsTheTask(t *testing.T) { | |
| 110 | + // Six tool-calling turns, then a compaction, then the answer. | |
| 111 | + var script []string | |
| 112 | + for i := 0; i < 6; i++ { | |
| 113 | + script = append(script, msgWithTokens("tool_use", 100, toolUseBlock("tu", "boom", `{}`))) | |
| 114 | + } | |
| 115 | + script = append(script, msgWithTokens("end_turn", 100, textBlock("all done"))) | |
| 116 | + f := newFakeAPI(t, script...) | |
| 117 | + | |
| 118 | + var logBuf bytes.Buffer | |
| 119 | + a := newTestAgent(t, f, t.TempDir(), &logBuf) | |
| 120 | + a.opts.ContextWindow = 1000 // 70% of 1000, and each fake turn reports 100 | |
| 121 | + a.opts.CompactAt = 0.1 | |
| 122 | + | |
| 123 | + var compacted int | |
| 124 | + a.Observe = func(ev Event) { | |
| 125 | + if ev.Kind == EventCompact { | |
| 126 | + compacted++ | |
| 127 | + } | |
| 128 | + } | |
| 129 | + if _, err := a.Run(context.Background(), "fix the failing test in loop.go"); err != nil { | |
| 130 | + t.Fatalf("Run: %v", err) | |
| 131 | + } | |
| 132 | + if compacted == 0 { | |
| 133 | + t.Fatal("expected a compaction") | |
| 134 | + } | |
| 135 | + | |
| 136 | + history := a.State().History() | |
| 137 | + if history[0].Role != llm.RoleUser { | |
| 138 | + t.Fatal("history must start with a user message") | |
| 139 | + } | |
| 140 | + folded := history[0].Content[0].Text | |
| 141 | + for _, want := range []string{"[Earlier conversation, compacted]", "fix the failing test in loop.go", "the config loads from X not Y"} { | |
| 142 | + if !strings.Contains(folded, want) { | |
| 143 | + t.Errorf("folded message missing %q:\n%s", want, folded) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + if history[1].Role != llm.RoleAssistant { | |
| 147 | + t.Errorf("message after the summary is %q, want assistant", history[1].Role) | |
| 148 | + } | |
| 149 | + if !strings.Contains(logBuf.String(), `"type":"compaction"`) { | |
| 150 | + t.Error("compaction should be logged; it is a cost and cache event") | |
| 151 | + } | |
| 152 | + | |
| 153 | + // The summarization call must not be able to touch the repository. | |
| 154 | + for i, req := range f.requests { | |
| 155 | + if len(req.Tools) == 0 && len(req.Messages) > 0 { | |
| 156 | + if req.MaxTokens != 4096 { | |
| 157 | + t.Errorf("request %d looks like the compaction call but has max_tokens %d", i, req.MaxTokens) | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + func TestStripTrailingToolUse(t *testing.T) { | |
| 164 | + msgs := []llm.Message{ | |
| 165 | + {Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("go")}}, | |
| 166 | + {Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "tu", Name: "x"}}}, | |
| 167 | + } | |
| 168 | + if got := stripTrailingToolUse(msgs); len(got) != 1 { | |
| 169 | + t.Errorf("a dangling tool_use must be dropped, got %d messages", len(got)) | |
| 170 | + } | |
| 171 | + keep := []llm.Message{ | |
| 172 | + {Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("go")}}, | |
| 173 | + {Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("done")}}, | |
| 174 | + } | |
| 175 | + if got := stripTrailingToolUse(keep); len(got) != 2 { | |
| 176 | + t.Errorf("a text-only assistant turn should be kept, got %d", len(got)) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + func TestSupersedeStaleReads(t *testing.T) { | |
| 181 | + root := t.TempDir() | |
| 182 | + path := filepath.Join(root, "a.go") | |
| 183 | + os.WriteFile(path, []byte("package a\n\nconst X = 1\n"), 0o644) | |
| 184 | + | |
| 185 | + f := newFakeAPI(t, | |
| 186 | + msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)), | |
| 187 | + msg("tool_use", toolUseBlock("tu_2", "read_file", `{"path":"a.go"}`)), | |
| 188 | + msg("tool_use", toolUseBlock("tu_3", "edit_file", `{"path":"a.go","old_str":"const X = 1","new_str":"const X = 2"}`)), | |
| 189 | + msg("end_turn", textBlock("done")), | |
| 190 | + ) | |
| 191 | + var logBuf bytes.Buffer | |
| 192 | + a := newTestAgent(t, f, root, &logBuf) | |
| 193 | + if _, err := a.Run(context.Background(), "bump X"); err != nil { | |
| 194 | + t.Fatalf("Run: %v", err) | |
| 195 | + } | |
| 196 | + | |
| 197 | + var superseded, intact int | |
| 198 | + for _, m := range a.State().History() { | |
| 199 | + for _, b := range m.Content { | |
| 200 | + if b.Type != llm.BlockToolResult { | |
| 201 | + continue | |
| 202 | + } | |
| 203 | + switch { | |
| 204 | + case strings.HasPrefix(b.Content, "[superseded:"): | |
| 205 | + superseded++ | |
| 206 | + // Match the numbered read format, so the edit's own diff — which | |
| 207 | + // quotes the old line — is not counted as a surviving read. | |
| 208 | + case strings.Contains(b.Content, "\tconst X = 1"): | |
| 209 | + intact++ | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + if superseded != 1 { | |
| 214 | + t.Errorf("%d results superseded, want 1 (the earlier of two reads)", superseded) | |
| 215 | + } | |
| 216 | + if intact != 1 { | |
| 217 | + t.Errorf("%d intact reads, want 1 (the most recent read stays)", intact) | |
| 218 | + } | |
| 219 | + if !strings.Contains(logBuf.String(), `"type":"supersede_reads"`) { | |
| 220 | + t.Error("superseding should be logged") | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + func TestSupersedeLeavesASingleReadAlone(t *testing.T) { | |
| 225 | + root := t.TempDir() | |
| 226 | + os.WriteFile(filepath.Join(root, "a.go"), []byte("package a\n\nconst X = 1\n"), 0o644) | |
| 227 | + f := newFakeAPI(t, | |
| 228 | + msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)), | |
| 229 | + msg("tool_use", toolUseBlock("tu_2", "edit_file", `{"path":"a.go","old_str":"const X = 1","new_str":"const X = 2"}`)), | |
| 230 | + msg("end_turn", textBlock("done")), | |
| 231 | + ) | |
| 232 | + a := newTestAgent(t, f, root, &bytes.Buffer{}) | |
| 233 | + if _, err := a.Run(context.Background(), "bump X"); err != nil { | |
| 234 | + t.Fatalf("Run: %v", err) | |
| 235 | + } | |
| 236 | + for _, m := range a.State().History() { | |
| 237 | + for _, b := range m.Content { | |
| 238 | + if strings.HasPrefix(b.Content, "[superseded:") { | |
| 239 | + t.Error("the only read of a file is still the current one; it must not be blanked") | |
| 240 | + } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 245 | + func TestReadPathParsing(t *testing.T) { | |
| 246 | + if got := readPath(json.RawMessage(`{"path":"x/y.go","offset":3}`)); got != "x/y.go" { | |
| 247 | + t.Errorf("readPath = %q", got) | |
| 248 | + } | |
| 249 | + if got := readPath(json.RawMessage(`not json`)); got != "" { | |
| 250 | + t.Errorf("readPath = %q, want empty on malformed input", got) | |
| 251 | + } | |
| 252 | + } | |
| @@ -44,6 +44,12 @@ type Options struct { | ||
| 44 | 44 | System string |
| 45 | 45 | // Effort maps to output_config.effort. Empty means the API default (high). |
| 46 | 46 | Effort string |
| 47 | + // ContextWindow and CompactAt control when the conversation gets folded | |
| 48 | + // into a summary. Zero means the defaults. | |
| 49 | + ContextWindow int | |
| 50 | + CompactAt float64 | |
| 51 | + // NoCache disables prompt cache breakpoints, for measuring what they buy. | |
| 52 | + NoCache bool | |
| 47 | 53 | } |
| 48 | 54 | |
| 49 | 55 | // EventKind identifies what happened, for an observer to render. |
| @@ -55,6 +61,7 @@ const ( | ||
| 55 | 61 | EventToolStart EventKind = "tool_start" |
| 56 | 62 | EventToolResult EventKind = "tool_result" |
| 57 | 63 | EventUsage EventKind = "usage" |
| 64 | + EventCompact EventKind = "compact" | |
| 58 | 65 | ) |
| 59 | 66 | |
| 60 | 67 | // Event is what the loop reports to an observer. It carries plain data only — |
| @@ -80,6 +87,10 @@ type Agent struct { | ||
| 80 | 87 | |
| 81 | 88 | // Observe is called for every event. It may be nil. |
| 82 | 89 | Observe func(Event) |
| 90 | + | |
| 91 | + // lastInputTokens is the size of the previous request as the API measured | |
| 92 | + // it, cached and uncached parts together. | |
| 93 | + lastInputTokens int | |
| 83 | 94 | } |
| 84 | 95 | |
| 85 | 96 | // New wires an agent. state and log may be freshly created by the caller so the |
| @@ -111,11 +122,19 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { | ||
| 111 | 122 | for turn := 1; turn <= a.opts.MaxTurns; turn++ { |
| 112 | 123 | a.emit(Event{Kind: EventTurnStart, Turn: turn}) |
| 113 | 124 | |
| 125 | + // Compaction is checked before building the request, using the token | |
| 126 | + // count the API reported last turn — free and exact, where counting | |
| 127 | + // locally would be neither. | |
| 128 | + if _, err := a.maybeCompact(ctx, a.lastInputTokens); err != nil { | |
| 129 | + return "", err | |
| 130 | + } | |
| 131 | + | |
| 114 | 132 | resp, err := a.client.CreateMessage(ctx, a.request()) |
| 115 | 133 | if err != nil { |
| 116 | 134 | return "", err |
| 117 | 135 | } |
| 118 | 136 | a.state.AddUsage(resp.Usage) |
| 137 | + a.lastInputTokens = resp.Usage.InputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens | |
| 119 | 138 | a.log.Log(metrics.Event{ |
| 120 | 139 | Type: "turn", |
| 121 | 140 | Turn: turn, |
| @@ -184,6 +203,18 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock | ||
| 184 | 203 | if isErr { |
| 185 | 204 | payload = err.Error() |
| 186 | 205 | } |
| 206 | + // A file that was just changed makes every earlier read of it wrong, | |
| 207 | + // not merely stale. Blank those results rather than leave the model | |
| 208 | + // reasoning from contents that no longer exist. | |
| 209 | + if !isErr && (call.Name == "edit_file" || call.Name == "write_file") { | |
| 210 | + if p := editedPath(call.Input); p != "" { | |
| 211 | + if n := a.state.SupersedeReads(p); n > 0 { | |
| 212 | + a.log.Log(metrics.Event{Type: "supersede_reads", Tool: call.Name, | |
| 213 | + Extra: map[string]any{"path": p, "results_blanked": n}}) | |
| 214 | + } | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 187 | 218 | results = append(results, llm.ToolResultBlock(call.ID, payload, isErr)) |
| 188 | 219 | a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr}) |
| 189 | 220 | } |
| @@ -201,9 +232,25 @@ func (a *Agent) request() llm.Request { | ||
| 201 | 232 | InputSchema: t.InputSchema(), |
| 202 | 233 | }) |
| 203 | 234 | } |
| 235 | + system := llm.SystemBlock{Type: "text", Text: a.opts.System} | |
| 236 | + messages := a.state.History() | |
| 237 | + | |
| 238 | + if !a.opts.NoCache { | |
| 239 | + // Two breakpoints. The first caches the tools and system prompt, which | |
| 240 | + // never change. The second rolls forward over the conversation each | |
| 241 | + // turn, so every turn writes a small delta and reads a large prefix. | |
| 242 | + system.CacheControl = llm.Ephemeral() | |
| 243 | + if n := len(messages); n > 0 { | |
| 244 | + if blocks := messages[n-1].Content; len(blocks) > 0 { | |
| 245 | + messages[n-1].Content = append([]llm.ContentBlock(nil), blocks...) | |
| 246 | + messages[n-1].Content[len(blocks)-1].CacheControl = llm.Ephemeral() | |
| 247 | + } | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 204 | 251 | req := llm.Request{ |
| 205 | - System: []llm.SystemBlock{{Type: "text", Text: a.opts.System}}, | |
| 206 | - Messages: a.state.History(), | |
| 252 | + System: []llm.SystemBlock{system}, | |
| 253 | + Messages: messages, | |
| 207 | 254 | Tools: defs, |
| 208 | 255 | Thinking: llm.Adaptive(), |
| 209 | 256 | } |
| @@ -213,6 +260,17 @@ func (a *Agent) request() llm.Request { | ||
| 213 | 260 | return req |
| 214 | 261 | } |
| 215 | 262 | |
| 263 | + // editedPath pulls the path out of an edit_file or write_file call. | |
| 264 | + func editedPath(input json.RawMessage) string { | |
| 265 | + var args struct { | |
| 266 | + Path string `json:"path"` | |
| 267 | + } | |
| 268 | + if json.Unmarshal(input, &args) != nil { | |
| 269 | + return "" | |
| 270 | + } | |
| 271 | + return args.Path | |
| 272 | + } | |
| 273 | + | |
| 216 | 274 | func refusalError(resp *llm.Response) error { |
| 217 | 275 | var b strings.Builder |
| 218 | 276 | b.WriteString("the model declined this request") |
| @@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "os" |
| 12 | 12 | "path/filepath" |
| 13 | + "strconv" | |
| 13 | 14 | "strings" |
| 14 | 15 | "testing" |
| 15 | 16 | |
| @@ -24,11 +25,29 @@ type fakeAPI struct { | ||
| 24 | 25 | t *testing.T |
| 25 | 26 | responses []string |
| 26 | 27 | requests []llm.Request |
| 28 | + summary string | |
| 27 | 29 | srv *httptest.Server |
| 28 | 30 | } |
| 29 | 31 | |
| 32 | + // compactions counts requests that were summarization calls, so they do not | |
| 33 | + // consume scripted responses. | |
| 34 | + func (f *fakeAPI) compactions() int { | |
| 35 | + var n int | |
| 36 | + for _, req := range f.requests { | |
| 37 | + if len(req.Messages) > 0 { | |
| 38 | + last := req.Messages[len(req.Messages)-1] | |
| 39 | + for _, b := range last.Content { | |
| 40 | + if strings.Contains(b.Text, "Summarize the conversation above") { | |
| 41 | + n++ | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return n | |
| 47 | + } | |
| 48 | + | |
| 30 | 49 | func newFakeAPI(t *testing.T, responses ...string) *fakeAPI { |
| 31 | - f := &fakeAPI{t: t, responses: responses} | |
| 50 | + f := &fakeAPI{t: t, responses: responses, summary: "Compacted: edited loop.go, the config loads from X not Y."} | |
| 32 | 51 | f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 33 | 52 | body, _ := io.ReadAll(r.Body) |
| 34 | 53 | var req llm.Request |
| @@ -36,7 +55,11 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI { | ||
| 36 | 55 | t.Errorf("harness sent a body the API types cannot parse: %v\n%s", err, body) |
| 37 | 56 | } |
| 38 | 57 | f.requests = append(f.requests, req) |
| 39 | - i := len(f.requests) - 1 | |
| 58 | + if bytes.Contains(body, []byte("Summarize the conversation above")) { | |
| 59 | + io.WriteString(w, msg("end_turn", textBlock(f.summary))) | |
| 60 | + return | |
| 61 | + } | |
| 62 | + i := len(f.requests) - 1 - f.compactions() | |
| 40 | 63 | if i >= len(f.responses) { |
| 41 | 64 | t.Errorf("harness made %d requests, script only has %d", i+1, len(f.responses)) |
| 42 | 65 | w.WriteHeader(http.StatusInternalServerError) |
| @@ -65,6 +88,13 @@ func msg(stopReason string, blocks ...string) string { | ||
| 65 | 88 | |
| 66 | 89 | func textBlock(s string) string { return `{"type":"text","text":"` + s + `"}` } |
| 67 | 90 | |
| 91 | + // msgWithTokens is msg with a controllable input_tokens, for driving compaction. | |
| 92 | + func msgWithTokens(stopReason string, inputTokens int, blocks ...string) string { | |
| 93 | + return `{"id":"msg","type":"message","role":"assistant","model":"claude-opus-5","content":[` + | |
| 94 | + strings.Join(blocks, ",") + `],"stop_reason":"` + stopReason + `",` + | |
| 95 | + `"usage":{"input_tokens":` + strconv.Itoa(inputTokens) + `,"output_tokens":20}}` | |
| 96 | + } | |
| 97 | + | |
| 68 | 98 | func toolUseBlock(id, name, input string) string { |
| 69 | 99 | return `{"type":"tool_use","id":"` + id + `","name":"` + name + `","input":` + input + `}` |
| 70 | 100 | } |
| @@ -77,6 +107,8 @@ func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) * | ||
| 77 | 107 | state := NewState() |
| 78 | 108 | reg := tools.NewRegistry(log).Register( |
| 79 | 109 | tools.NewRead(root, state), |
| 110 | + tools.NewEdit(root, state), | |
| 111 | + tools.NewWrite(root, state), | |
| 80 | 112 | tools.New("boom", "Always fails", func(context.Context, struct{}) (string, error) { |
| 81 | 113 | return "", errors.New("detonated") |
| 82 | 114 | }), |
| @@ -222,8 +254,12 @@ func TestRequestShape(t *testing.T) { | ||
| 222 | 254 | if req.Thinking == nil || req.Thinking.Type != "adaptive" { |
| 223 | 255 | t.Errorf("thinking = %+v, want adaptive (budget_tokens is a 400 on current models)", req.Thinking) |
| 224 | 256 | } |
| 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) | |
| 257 | + var names []string | |
| 258 | + for _, tl := range req.Tools { | |
| 259 | + names = append(names, tl.Name) | |
| 260 | + } | |
| 261 | + if want := "boom, edit_file, read_file, write_file"; strings.Join(names, ", ") != want { | |
| 262 | + t.Errorf("tools = %v, want them sorted for a byte-stable prefix (%s)", names, want) | |
| 227 | 263 | } |
| 228 | 264 | for _, tl := range req.Tools { |
| 229 | 265 | if len(tl.InputSchema) == 0 { |
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | package agent |
| 6 | 6 | |
| 7 | 7 | import ( |
| 8 | + "encoding/json" | |
| 9 | + "strings" | |
| 8 | 10 | "sync" |
| 9 | 11 | "time" |
| 10 | 12 | |
| @@ -51,6 +53,73 @@ func (s *State) Append(m llm.Message) { | ||
| 51 | 53 | s.messages = append(s.messages, m) |
| 52 | 54 | } |
| 53 | 55 | |
| 56 | + // Replace swaps the whole history, which is how compaction lands. | |
| 57 | + func (s *State) Replace(msgs []llm.Message) { | |
| 58 | + s.mu.Lock() | |
| 59 | + defer s.mu.Unlock() | |
| 60 | + s.messages = append([]llm.Message(nil), msgs...) | |
| 61 | + } | |
| 62 | + | |
| 63 | + // SupersedeReads blanks the results of every read_file call for path except the | |
| 64 | + // most recent one. A file read twenty turns ago and edited since is not merely | |
| 65 | + // wasted context — it is wrong, and the model will reason from it. | |
| 66 | + func (s *State) SupersedeReads(path string) int { | |
| 67 | + s.mu.Lock() | |
| 68 | + defer s.mu.Unlock() | |
| 69 | + | |
| 70 | + // Collect the tool_use ids of every read of this path, in order. | |
| 71 | + var ids []string | |
| 72 | + for _, m := range s.messages { | |
| 73 | + if m.Role != llm.RoleAssistant { | |
| 74 | + continue | |
| 75 | + } | |
| 76 | + for _, b := range m.Content { | |
| 77 | + if b.Type == llm.BlockToolUse && b.Name == "read_file" && readPath(b.Input) == path { | |
| 78 | + ids = append(ids, b.ID) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + } | |
| 82 | + if len(ids) <= 1 { | |
| 83 | + return 0 | |
| 84 | + } | |
| 85 | + stale := map[string]bool{} | |
| 86 | + for _, id := range ids[:len(ids)-1] { | |
| 87 | + stale[id] = true | |
| 88 | + } | |
| 89 | + | |
| 90 | + var n int | |
| 91 | + for i, m := range s.messages { | |
| 92 | + if m.Role != llm.RoleUser { | |
| 93 | + continue | |
| 94 | + } | |
| 95 | + for j, b := range m.Content { | |
| 96 | + if b.Type != llm.BlockToolResult || !stale[b.ToolUseID] { | |
| 97 | + continue | |
| 98 | + } | |
| 99 | + if strings.HasPrefix(b.Content, stalePrefix) { | |
| 100 | + continue | |
| 101 | + } | |
| 102 | + s.messages[i].Content[j].Content = stalePrefix + path + | |
| 103 | + " has been modified since this read. The contents shown here are out of date; read it again if you need them.]" | |
| 104 | + n++ | |
| 105 | + } | |
| 106 | + } | |
| 107 | + return n | |
| 108 | + } | |
| 109 | + | |
| 110 | + const stalePrefix = "[superseded: " | |
| 111 | + | |
| 112 | + // readPath pulls the path argument out of a read_file call. | |
| 113 | + func readPath(input json.RawMessage) string { | |
| 114 | + var args struct { | |
| 115 | + Path string `json:"path"` | |
| 116 | + } | |
| 117 | + if json.Unmarshal(input, &args) != nil { | |
| 118 | + return "" | |
| 119 | + } | |
| 120 | + return args.Path | |
| 121 | + } | |
| 122 | + | |
| 54 | 123 | // History returns a copy of the conversation so far. |
| 55 | 124 | func (s *State) History() []llm.Message { |
| 56 | 125 | s.mu.Lock() |
| @@ -64,6 +64,9 @@ type ContentBlock struct { | ||
| 64 | 64 | Content string `json:"content,omitempty"` |
| 65 | 65 | IsError bool `json:"is_error,omitempty"` |
| 66 | 66 | |
| 67 | + // CacheControl marks this block as a cache breakpoint. | |
| 68 | + CacheControl *CacheControl `json:"cache_control,omitempty"` | |
| 69 | + | |
| 67 | 70 | // raw holds the original bytes of a block type this struct does not model, |
| 68 | 71 | // so an unfamiliar block still round-trips instead of being flattened away. |
| 69 | 72 | raw json.RawMessage |
| @@ -127,6 +130,9 @@ func (b ContentBlock) MarshalJSON() ([]byte, error) { | ||
| 127 | 130 | } |
| 128 | 131 | return nil, fmt.Errorf("llm: cannot marshal unknown content block %q", b.Type) |
| 129 | 132 | } |
| 133 | + if b.CacheControl != nil { | |
| 134 | + m["cache_control"] = b.CacheControl | |
| 135 | + } | |
| 130 | 136 | return json.Marshal(m) |
| 131 | 137 | } |
| 132 | 138 | |
| @@ -44,6 +44,8 @@ func (r *Renderer) Handle(ev agent.Event) { | ||
| 44 | 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 | 45 | case agent.EventToolResult: |
| 46 | 46 | r.tool(ev) |
| 47 | + case agent.EventCompact: | |
| 48 | + fmt.Fprintf(r.W, "%s[compacted the conversation]%s\n", color.dim, color.reset) | |
| 47 | 49 | } |
| 48 | 50 | } |
| 49 | 51 | |