Vectrum
Sign In

Phase 4: prompt caching, compaction, stale-read superseding

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>
Jjonicommitted 1 parent daac0bdcommite8e53e8

Showing 9 changed files with 622 additions and 10 deletions.

+10−1cmd/harness/main.goView File
@@ -34,6 +34,9 @@ func run() error {
3434 authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)")
3535 effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
3636 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")
3740 logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
3841 prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
3942 verbose := flag.Bool("v", false, "print full tool output")
@@ -82,7 +85,13 @@ func run() error {
8285 )
8386
8487 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+ })
8695 a.Observe = render.Handle
8796
8897 ctx := context.Background()
+10−3evals/runner.goView File
@@ -69,6 +69,8 @@ func run() error {
6969 model := flag.String("model", "", "model id")
7070 effort := flag.String("effort", "", "output_config.effort")
7171 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")
7274 timeout := flag.Duration("timeout", 10*time.Minute, "wall-clock cap per run")
7375 tasksDir := flag.String("dir", "evals/tasks", "directory of task definitions")
7476 outDir := flag.String("out", "evals/runs", "where to persist transcripts")
@@ -118,7 +120,12 @@ func run() error {
118120 sem <- struct{}{}
119121 defer func() { <-sem }()
120122
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)
122129 mu.Lock()
123130 results = append(results, res)
124131 mu.Unlock()
@@ -151,7 +158,7 @@ func run() error {
151158 // verifies the result.
152159 // The return value is named so the deferred timing assignment lands in the
153160 // 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) {
155162 res = Result{Task: task.Name, Run: runNo}
156163 start := time.Now()
157164 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
185192 tools.NewWrite(work, state),
186193 tools.NewBash(work),
187194 )
188- a := agent.New(client, reg, state, log, agent.Options{MaxTurns: maxTurns, Effort: effort})
195+ a := agent.New(client, reg, state, log, opts)
189196
190197 answer, runErr := a.Run(ctx, task.Prompt)
191198 res.Answer = answer
+173−0internal/agent/compact.goaddedView File
@@ -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+ }
+252−0internal/agent/compact_test.goaddedView File
@@ -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+ }
+60−2internal/agent/loop.goView File
@@ -44,6 +44,12 @@ type Options struct {
4444 System string
4545 // Effort maps to output_config.effort. Empty means the API default (high).
4646 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
4753 }
4854
4955 // EventKind identifies what happened, for an observer to render.
@@ -55,6 +61,7 @@ const (
5561 EventToolStart EventKind = "tool_start"
5662 EventToolResult EventKind = "tool_result"
5763 EventUsage EventKind = "usage"
64+ EventCompact EventKind = "compact"
5865 )
5966
6067 // Event is what the loop reports to an observer. It carries plain data only —
@@ -80,6 +87,10 @@ type Agent struct {
8087
8188 // Observe is called for every event. It may be nil.
8289 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
8394 }
8495
8596 // 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) {
111122 for turn := 1; turn <= a.opts.MaxTurns; turn++ {
112123 a.emit(Event{Kind: EventTurnStart, Turn: turn})
113124
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+
114132 resp, err := a.client.CreateMessage(ctx, a.request())
115133 if err != nil {
116134 return "", err
117135 }
118136 a.state.AddUsage(resp.Usage)
137+ a.lastInputTokens = resp.Usage.InputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens
119138 a.log.Log(metrics.Event{
120139 Type: "turn",
121140 Turn: turn,
@@ -184,6 +203,18 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock
184203 if isErr {
185204 payload = err.Error()
186205 }
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+
187218 results = append(results, llm.ToolResultBlock(call.ID, payload, isErr))
188219 a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr})
189220 }
@@ -201,9 +232,25 @@ func (a *Agent) request() llm.Request {
201232 InputSchema: t.InputSchema(),
202233 })
203234 }
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+
204251 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,
207254 Tools: defs,
208255 Thinking: llm.Adaptive(),
209256 }
@@ -213,6 +260,17 @@ func (a *Agent) request() llm.Request {
213260 return req
214261 }
215262
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+
216274 func refusalError(resp *llm.Response) error {
217275 var b strings.Builder
218276 b.WriteString("the model declined this request")
+40−4internal/agent/loop_test.goView File
@@ -10,6 +10,7 @@ import (
1010 "net/http/httptest"
1111 "os"
1212 "path/filepath"
13+ "strconv"
1314 "strings"
1415 "testing"
1516
@@ -24,11 +25,29 @@ type fakeAPI struct {
2425 t *testing.T
2526 responses []string
2627 requests []llm.Request
28+ summary string
2729 srv *httptest.Server
2830 }
2931
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+
3049 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."}
3251 f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3352 body, _ := io.ReadAll(r.Body)
3453 var req llm.Request
@@ -36,7 +55,11 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI {
3655 t.Errorf("harness sent a body the API types cannot parse: %v\n%s", err, body)
3756 }
3857 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()
4063 if i >= len(f.responses) {
4164 t.Errorf("harness made %d requests, script only has %d", i+1, len(f.responses))
4265 w.WriteHeader(http.StatusInternalServerError)
@@ -65,6 +88,13 @@ func msg(stopReason string, blocks ...string) string {
6588
6689 func textBlock(s string) string { return `{"type":"text","text":"` + s + `"}` }
6790
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+
6898 func toolUseBlock(id, name, input string) string {
6999 return `{"type":"tool_use","id":"` + id + `","name":"` + name + `","input":` + input + `}`
70100 }
@@ -77,6 +107,8 @@ func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) *
77107 state := NewState()
78108 reg := tools.NewRegistry(log).Register(
79109 tools.NewRead(root, state),
110+ tools.NewEdit(root, state),
111+ tools.NewWrite(root, state),
80112 tools.New("boom", "Always fails", func(context.Context, struct{}) (string, error) {
81113 return "", errors.New("detonated")
82114 }),
@@ -222,8 +254,12 @@ func TestRequestShape(t *testing.T) {
222254 if req.Thinking == nil || req.Thinking.Type != "adaptive" {
223255 t.Errorf("thinking = %+v, want adaptive (budget_tokens is a 400 on current models)", req.Thinking)
224256 }
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)
227263 }
228264 for _, tl := range req.Tools {
229265 if len(tl.InputSchema) == 0 {
+69−0internal/agent/state.goView File
@@ -5,6 +5,8 @@
55 package agent
66
77 import (
8+ "encoding/json"
9+ "strings"
810 "sync"
911 "time"
1012
@@ -51,6 +53,73 @@ func (s *State) Append(m llm.Message) {
5153 s.messages = append(s.messages, m)
5254 }
5355
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+
54123 // History returns a copy of the conversation so far.
55124 func (s *State) History() []llm.Message {
56125 s.mu.Lock()
+6−0internal/llm/types.goView File
@@ -64,6 +64,9 @@ type ContentBlock struct {
6464 Content string `json:"content,omitempty"`
6565 IsError bool `json:"is_error,omitempty"`
6666
67+ // CacheControl marks this block as a cache breakpoint.
68+ CacheControl *CacheControl `json:"cache_control,omitempty"`
69+
6770 // raw holds the original bytes of a block type this struct does not model,
6871 // so an unfamiliar block still round-trips instead of being flattened away.
6972 raw json.RawMessage
@@ -127,6 +130,9 @@ func (b ContentBlock) MarshalJSON() ([]byte, error) {
127130 }
128131 return nil, fmt.Errorf("llm: cannot marshal unknown content block %q", b.Type)
129132 }
133+ if b.CacheControl != nil {
134+ m["cache_control"] = b.CacheControl
135+ }
130136 return json.Marshal(m)
131137 }
132138
+2−0internal/ui/render.goView File
@@ -44,6 +44,8 @@ func (r *Renderer) Handle(ev agent.Event) {
4444 fmt.Fprintf(r.W, "%s⏺ %s%s%s(%s)%s\n", color.cyan, color.bold, ev.Tool, color.reset, summarizeInput(ev.Input), color.reset)
4545 case agent.EventToolResult:
4646 r.tool(ev)
47+ case agent.EventCompact:
48+ fmt.Fprintf(r.W, "%s[compacted the conversation]%s\n", color.dim, color.reset)
4749 }
4850 }
4951