diff --git a/cmd/harness/main.go b/cmd/harness/main.go index b9d3733..1182e23 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -382,18 +382,21 @@ func runCmd(args []string) error { lateAPI.Bind(newLazyRunClientAPI(func() *engine.Session { return sess })) s, err := resolveSession(engine.Config{ - Providers: registry(cfg), - Model: model, - System: systemPrompt(workDir, opts.system), - MaxTokens: opts.maxTokens, - WorkDir: workDir, - SessionDir: sesDir, - OnEvent: onEvent, - Instructions: instructionsConfig(cfg, opts.noInstructions), - SkillsDirs: skillsDirs(cfg, opts.skillsDirs, workDir), - Hooks: pluginHooks(host), - MCP: mcpRegistry(mcpMgr), - Processes: processRegistry(procMgr), + Providers: registry(cfg), + Model: model, + System: systemPrompt(workDir, opts.system), + MaxTokens: opts.maxTokens, + WorkDir: workDir, + SessionDir: sesDir, + OnEvent: onEvent, + Instructions: instructionsConfig(cfg, opts.noInstructions), + SkillsDirs: skillsDirs(cfg, opts.skillsDirs, workDir), + Hooks: pluginHooks(host), + MCP: mcpRegistry(mcpMgr), + Processes: processRegistry(procMgr), + ContextWindowTokens: cfg.ContextWindowTokens, + CompactionThreshold: cfg.CompactionThreshold, + CompactionKeepTurns: cfg.CompactionKeepTurns, }, opts.resume, opts.cont, modelSet) if err != nil { return err @@ -693,17 +696,20 @@ func serveCmd(args []string) error { var srv *server.Server mkCfg := func(model message.ModelRef) engine.Config { return engine.Config{ - Providers: reg, - Model: model, - System: systemPrompt(workDir, ""), - WorkDir: workDir, - SessionDir: sesDir, - OnEvent: func(ev engine.Event) { srv.Publish(ev) }, - Instructions: instructionsConfig(cfg, noInstructions), - SkillsDirs: skillsDirs(cfg, skillDirs, workDir), - Hooks: pluginHooks(pluginHost), - MCP: mcpRegistry(mcpMgr), - Processes: processRegistry(procMgr), + Providers: reg, + Model: model, + System: systemPrompt(workDir, ""), + WorkDir: workDir, + SessionDir: sesDir, + OnEvent: func(ev engine.Event) { srv.Publish(ev) }, + Instructions: instructionsConfig(cfg, noInstructions), + SkillsDirs: skillsDirs(cfg, skillDirs, workDir), + Hooks: pluginHooks(pluginHost), + MCP: mcpRegistry(mcpMgr), + Processes: processRegistry(procMgr), + ContextWindowTokens: cfg.ContextWindowTokens, + CompactionThreshold: cfg.CompactionThreshold, + CompactionKeepTurns: cfg.CompactionKeepTurns, } } srv, err = server.New(server.Options{ diff --git a/config/config.go b/config/config.go index 52e3e01..d29e223 100644 --- a/config/config.go +++ b/config/config.go @@ -79,6 +79,21 @@ type Config struct { // configures no processes. Merge rules mirror MCPServers: keys merge, // but a same-name project entry replaces the user entry wholesale. Processes map[string]ProcessSpec `json:"processes,omitempty"` + // ContextWindowTokens sets engine.Config.ContextWindowTokens for every + // session this process creates: the model's context window size, in + // tokens. Zero (omitted, the default) disables automatic compaction + // entirely — see docs/design/context-compaction.md and issue #62 layer + // 3. Opt-in: the engine has no built-in per-model table. + ContextWindowTokens int `json:"context_window_tokens,omitempty"` + // CompactionThreshold sets engine.Config.CompactionThreshold: the + // fraction of ContextWindowTokens at which automatic compaction + // triggers. Zero (omitted) defaults to 0.8 (see the engine). + CompactionThreshold float64 `json:"compaction_threshold,omitempty"` + // CompactionKeepTurns sets engine.Config.CompactionKeepTurns: how many + // of the most recent turns automatic compaction always keeps verbatim. + // Zero (omitted) defaults to 2 (see the engine); the effective value + // can never go below 1. + CompactionKeepTurns int `json:"compaction_keep_turns,omitempty"` } // ProcessSpec configures one managed process (package engine's @@ -520,6 +535,15 @@ func merge(base, over *Config) *Config { if over.GoalEvaluatorModel != "" { out.GoalEvaluatorModel = over.GoalEvaluatorModel } + if over.ContextWindowTokens != 0 { + out.ContextWindowTokens = over.ContextWindowTokens + } + if over.CompactionThreshold != 0 { + out.CompactionThreshold = over.CompactionThreshold + } + if over.CompactionKeepTurns != 0 { + out.CompactionKeepTurns = over.CompactionKeepTurns + } // Arrays override wholesale: a non-empty project value replaces the user // value entirely; otherwise inherit. Copy so the merged config never // aliases either input's slice. diff --git a/config/config_test.go b/config/config_test.go index 83de720..d368d37 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -483,6 +483,37 @@ func TestLoadSkillsDirs(t *testing.T) { }) } +// TestMergeCompactionFields is the red-first test for docs/design/context- +// compaction.md's config fields: project non-zero values override the user +// layer, same scalar-override rule as GoalEvaluatorModel. +func TestMergeCompactionFields(t *testing.T) { + base := &Config{ContextWindowTokens: 100000, CompactionThreshold: 0.9, CompactionKeepTurns: 3} + t.Run("zero project values inherit the user layer", func(t *testing.T) { + got := merge(base, &Config{}) + if got.ContextWindowTokens != 100000 { + t.Errorf("ContextWindowTokens = %d, want inherited 100000", got.ContextWindowTokens) + } + if got.CompactionThreshold != 0.9 { + t.Errorf("CompactionThreshold = %v, want inherited 0.9", got.CompactionThreshold) + } + if got.CompactionKeepTurns != 3 { + t.Errorf("CompactionKeepTurns = %d, want inherited 3", got.CompactionKeepTurns) + } + }) + t.Run("non-zero project values override", func(t *testing.T) { + got := merge(base, &Config{ContextWindowTokens: 50000, CompactionThreshold: 0.7, CompactionKeepTurns: 1}) + if got.ContextWindowTokens != 50000 { + t.Errorf("ContextWindowTokens = %d, want project override 50000", got.ContextWindowTokens) + } + if got.CompactionThreshold != 0.7 { + t.Errorf("CompactionThreshold = %v, want project override 0.7", got.CompactionThreshold) + } + if got.CompactionKeepTurns != 1 { + t.Errorf("CompactionKeepTurns = %d, want project override 1", got.CompactionKeepTurns) + } + }) +} + func TestMergeSkillsDirs(t *testing.T) { t.Run("non-empty project overrides user entirely", func(t *testing.T) { base := &Config{SkillsDirs: []string{"user/a", "user/b"}} diff --git a/e2e/compaction_test.go b/e2e/compaction_test.go new file mode 100644 index 0000000..4070e84 --- /dev/null +++ b/e2e/compaction_test.go @@ -0,0 +1,270 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// scriptedUsageAnthropic is a fake Anthropic Messages API that streams one +// scripted reply (text + explicit input/output token usage) per request, in +// order — driving a precise Usage/LastUsage trajectory across turns so a +// test can cross a compaction threshold deterministically, unlike +// fakeAnthropic (which only varies stall behavior). +type scriptedUsageAnthropic struct { + mu sync.Mutex + turns []scriptedUsageTurn + call int +} + +type scriptedUsageTurn struct { + text string + inputTokens int + outputTokens int +} + +func (f *scriptedUsageAnthropic) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + i := f.call + f.call++ + f.mu.Unlock() + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "no flusher", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if i >= len(f.turns) { + // Out-of-script request: fail loudly and visibly rather than + // hanging, so a scripting mistake shows up as a clear test failure. + io.WriteString(w, sse("error", `{"type":"error","error":{"type":"invalid_request_error","message":"e2e: scriptedUsageAnthropic ran out of scripted turns"}}`)) + flusher.Flush() + return + } + turn := f.turns[i] + io.WriteString(w, completeTurnWithUsage(fmt.Sprintf("msg_%d", i), turn.text, turn.inputTokens, turn.outputTokens)) + flusher.Flush() +} + +// requestCount reports how many requests have been served so far. +func (f *scriptedUsageAnthropic) requestCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.call +} + +// writeCompactionConfig writes a harness config pointing the anthropic +// provider at the fake server and enabling automatic compaction with a +// small, test-configured threshold. +func writeCompactionConfig(t *testing.T, baseURL string, contextWindowTokens int, keepTurns int) string { + t.Helper() + cfg := map[string]any{ + "model": "anthropic/claude-fable-5", + "providers": map[string]any{ + "anthropic": map[string]any{ + "api_key_env": "ANTHROPIC_API_KEY", + "base_url": baseURL, + }, + }, + "context_window_tokens": contextWindowTokens, + "compaction_keep_turns": keepTurns, + } + b, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +// sessionUsageJSON is the subset of GET /session's usage sub-object this +// test needs. +type sessionUsageJSON struct { + InputTokens int `json:"input_tokens"` + LastInputTokens int `json:"last_input_tokens"` +} + +// sessionCompactionJSON is the subset of GET /session this test needs. +type sessionCompactionJSON struct { + Messages int `json:"messages"` + Usage sessionUsageJSON `json:"usage"` + CompactionCount int `json:"compaction_count"` + LastCompactedAt time.Time `json:"last_compacted_at"` +} + +func (p *serveProc) getSession(id string) sessionCompactionJSON { + p.t.Helper() + resp, data := p.do(http.MethodGet, "/session/"+id, nil) + if resp.StatusCode != http.StatusOK { + p.t.Fatalf("get session: status %d body %s", resp.StatusCode, data) + } + var s sessionCompactionJSON + if err := json.Unmarshal(data, &s); err != nil { + p.t.Fatalf("decode session: %v (%s)", err, data) + } + return s +} + +// TestAutoCompactionAcrossRestart is the e2e keystone for docs/design/ +// context-compaction.md: a real `harness serve` binary, driven by a scripted +// fake Anthropic provider, is pushed past a small test-configured +// context-window threshold; automatic compaction must fire, the session +// must keep working afterward with a visibly smaller usage picture, and a +// hard restart (SIGKILL, fresh process, same session dir) must replay the +// compact journal record and the trimmed history correctly — a post- +// compaction session restarts exactly as cleanly as an ordinary one. +func TestAutoCompactionAcrossRestart(t *testing.T) { + skipShort(t) + + fake := &scriptedUsageAnthropic{turns: []scriptedUsageTurn{ + {text: "reply one", inputTokens: 100, outputTokens: 10}, // turn 1: under threshold + {text: "reply two", inputTokens: 900, outputTokens: 10}, // turn 2: over threshold (800), triggers compaction before turn 3 + {text: "the gist of turns one and two", inputTokens: 40, outputTokens: 15}, // compaction's own summarization call + {text: "reply three", inputTokens: 120, outputTokens: 10}, // turn 3: proceeds normally post-compaction + {text: "reply four", inputTokens: 130, outputTokens: 10}, // turn 4: after restart, still working + }} + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + + sessDir := t.TempDir() + // context_window_tokens=1000, default threshold 0.8 => 800; keep_turns=1 + // so folding can actually happen with just 2 completed turns. + cfgPath := writeCompactionConfig(t, srv.URL, 1000, 1) + + p1 := startServe(t, sessDir, cfgPath) + id := p1.createSession() + + p1.prompt(id, "go1") + p1.waitMessages(id, 2) // user1, assistant1 + + p1.prompt(id, "go2") + p1.waitMessages(id, 4) // user1, assistant1, user2, assistant2 + + before := p1.getSession(id) + if before.CompactionCount != 0 { + t.Fatalf("CompactionCount before turn 3 = %d, want 0", before.CompactionCount) + } + if before.Usage.LastInputTokens != 900 { + t.Fatalf("LastInputTokens before turn 3 = %d, want 900 (turn 2's usage)", before.Usage.LastInputTokens) + } + + // Turn 3: maybeAutoCompact must fire BEFORE this turn's own request — + // folding turn 1 into a summary (keep_turns=1 keeps turn 2 verbatim) — + // so the scripted provider sees the summarization call as its 3rd + // request and turn 3's own worker call as its 4th, in that order. + p1.prompt(id, "go3") + // Post-compaction history: summary(1) + turn2(2) + turn3(2) = 5. + final := p1.waitMessages(id, 5) + assertUniqueMessageIDs(t, final) + if final[0].Role != "user" { + t.Fatalf("messages[0].Role = %q, want user (the compaction summary)", final[0].Role) + } + if textOf(final[0]) == "" { + t.Fatalf("compaction summary message has no text") + } + + after := p1.getSession(id) + if after.CompactionCount != 1 { + t.Fatalf("CompactionCount after turn 3 = %d, want 1 (automatic compaction must have fired)", after.CompactionCount) + } + if after.LastCompactedAt.IsZero() { + t.Error("LastCompactedAt is zero after a successful compaction") + } + // The session must keep working: turn 3's own usage becomes the new + // LastInputTokens, and it must be visibly smaller than the 900 that + // triggered compaction — the "usage drop" the e2e is required to show. + if after.Usage.LastInputTokens != 120 { + t.Fatalf("LastInputTokens after turn 3 = %d, want 120 (turn 3's own usage, not the compaction call's)", after.Usage.LastInputTokens) + } + if after.Usage.LastInputTokens >= before.Usage.LastInputTokens { + t.Errorf("LastInputTokens did not drop: before=%d after=%d", before.Usage.LastInputTokens, after.Usage.LastInputTokens) + } + + if got := fake.requestCount(); got != 4 { + t.Fatalf("provider requests = %d, want 4 (2 worker turns + 1 compaction summary + 1 more worker turn)", got) + } + + // The durable journal shows the summary message BEFORE history.compacted, + // and history.compacted names the fold. + events := p1.eventReplay() + assertContiguousSeqs(t, events) + var summaryID string + var sawSummaryMessage, sawCompacted bool + for _, ev := range events { + if ev.SessionID != id { + continue + } + switch ev.Type { + case "message": + if ev.Message != nil && ev.Message.ID == final[0].ID { + sawSummaryMessage = true + summaryID = ev.Message.ID + } + case "history.compacted": + if !sawSummaryMessage { + t.Fatal("history.compacted event/record arrived before the summary's message event") + } + sawCompacted = true + if ev.CompactTurnsFolded != 1 { + t.Errorf("history.compacted turns_folded = %d, want 1", ev.CompactTurnsFolded) + } + if ev.CompactSummaryID != summaryID { + t.Errorf("history.compacted summary id = %q, want %q", ev.CompactSummaryID, summaryID) + } + } + } + if !sawSummaryMessage { + t.Fatal("never saw the summary's message event in the journal replay") + } + if !sawCompacted { + t.Fatal("never saw a history.compacted event in the journal replay") + } + + // --- restart: SIGKILL, fresh process, same session dir ------------- + p1.kill() + p2 := startServe(t, sessDir, cfgPath) + + restarted := p2.getSession(id) + if restarted.CompactionCount != 1 { + t.Errorf("CompactionCount after restart = %d, want 1 (compact record must replay)", restarted.CompactionCount) + } + if restarted.LastCompactedAt.IsZero() { + t.Error("LastCompactedAt after restart is zero, want the compaction's timestamp to survive") + } + if restarted.Messages != 5 { + t.Fatalf("Messages after restart = %d, want 5 (trimmed history must survive)", restarted.Messages) + } + + msgs := p2.messages(id) + assertUniqueMessageIDs(t, msgs) + if len(msgs) != 5 { + t.Fatalf("messages after restart = %d, want 5", len(msgs)) + } + if msgs[0].ID != final[0].ID || msgs[0].Role != "user" { + t.Fatalf("messages[0] after restart = %+v, want the same summary message", msgs[0]) + } + + // The journal replay is still contiguous and reconciled after restart. + assertContiguousSeqs(t, p2.eventReplay()) + + // A post-compaction session must restart cleanly and keep working: a + // further prompt succeeds end to end. + p2.prompt(id, "go4") + final2 := p2.waitMessages(id, 7) // + user4, assistant4 + assertUniqueMessageIDs(t, final2) + if got := final2[len(final2)-1]; got.Role != "assistant" || textOf(got) != "reply four" { + t.Fatalf("final message after restart+prompt = %+v, want assistant \"reply four\"", got) + } +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c21b9b7..06d0666 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -154,12 +154,20 @@ func sse(name, data string) string { // completeTurn is a full end_turn SSE stream with a single text block. func completeTurn(msgID, text string) string { + return completeTurnWithUsage(msgID, text, 5, 3) +} + +// completeTurnWithUsage is completeTurn with explicit input/output token +// counts, so a test can script a precise Usage/LastUsage trajectory (e.g. +// driving a session's LastUsage past a compaction threshold — see +// compaction_test.go). +func completeTurnWithUsage(msgID, text string, inputTokens, outputTokens int) string { return strings.Join([]string{ - sse("message_start", fmt.Sprintf(`{"type":"message_start","message":{"id":%q,"usage":{"input_tokens":5}}}`, msgID)), + sse("message_start", fmt.Sprintf(`{"type":"message_start","message":{"id":%q,"usage":{"input_tokens":%d}}}`, msgID, inputTokens)), sse("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), sse("content_block_delta", fmt.Sprintf(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":%q}}`, text)), sse("content_block_stop", `{"type":"content_block_stop","index":0}`), - sse("message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}`), + sse("message_delta", fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":%d}}`, outputTokens)), sse("message_stop", `{"type":"message_stop"}`), }, "") } @@ -423,6 +431,12 @@ type apiEvent struct { GoalReason string `json:"goal_reason"` GoalMet bool `json:"goal_met"` GoalTurn int `json:"goal_turn"` + // Compaction fields (docs/design/context-compaction.md §4); see + // compaction_test.go. + CompactFirstID string `json:"compact_first_id"` + CompactLastID string `json:"compact_last_id"` + CompactTurnsFolded int `json:"compact_turns_folded"` + CompactSummaryID string `json:"compact_summary_id"` } // eventReplay connects to GET /event?from=0, reads the durable replay batch, diff --git a/engine/compact.go b/engine/compact.go new file mode 100644 index 0000000..c0f88f1 --- /dev/null +++ b/engine/compact.go @@ -0,0 +1,359 @@ +// Context compaction: summarize-and-truncate. See docs/design/ +// context-compaction.md for the full design; this file follows it exactly — +// where a comment here and that doc ever disagree, the doc wins. +package engine + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" +) + +// Compaction event types (see docs/design/context-compaction.md §4 "Live +// event surface"). EventHistoryCompacted is journaled durably (like +// session.status); EventCompactionFailed is fire-and-forget. +const ( + EventHistoryCompacted = "history.compacted" + EventCompactionFailed = "compaction.failed" +) + +// defaultCompactionThreshold is Config.CompactionThreshold's zero-fills-a- +// default value: the fraction of ContextWindowTokens at which automatic +// compaction triggers. +const defaultCompactionThreshold = 0.8 + +// defaultCompactionKeepTurns is Config.CompactionKeepTurns's zero-fills-a- +// default value. +const defaultCompactionKeepTurns = 2 + +// minCompactionKeepTurns is the hard floor on keep_turns (see CompactOptions +// and docs/design/context-compaction.md §1): the most recent turn is never +// foldable, so a session's history can never collapse to a lone summary the +// model would have to answer with zero real context. +const minCompactionKeepTurns = 1 + +// compactionMaxTokens bounds the summarization call's response — a concise +// summary, not another full turn. +const compactionMaxTokens = 1024 + +// CompactionSummaryBanner prefixes every synthesized compaction summary +// message's text, mirroring message.SyntheticOrphanResultText's spirit: a +// transcript or GET /session/{id}/message reader can never mistake it for +// something the human actually typed. +const CompactionSummaryBanner = "[compacted summary of earlier conversation]\n\n" + +// compactionSystemPrompt is the dedicated system prompt for the tool-less +// summarization call (see Session.Compact): concise, information-preserving, +// never tool-call minutiae verbatim. +const compactionSystemPrompt = `You are summarizing a prefix of an ongoing agent conversation so it can be folded into one message, freeing context for future turns. + +Write a concise, information-preserving summary. Preserve: +- the user's intent and goals +- decisions made and their rationale +- concrete facts a later turn depends on: file paths, commands, values, error text + +Do not transcribe tool-call arguments or outputs verbatim; describe what happened and why it matters instead. Be dense; omit anything a later turn would not need.` + +// CompactOptions configures one call to Session.Compact. +type CompactOptions struct { + // KeepTurns overrides Config.CompactionKeepTurns for this call only. + // Zero (the default) uses the config value (itself defaulting to 2 + // when zero). Whatever the source, the effective value is floored at + // minCompactionKeepTurns. + KeepTurns int + // Model overrides Config.CompactionModel / the session's own current + // model for this call only. Zero uses Config.CompactionModel, and if + // that is also zero, the session's current model (see Session.Model). + Model message.ModelRef +} + +// CompactResult is the outcome of a successful Session.Compact call. +// TurnsFolded is 0 (not an error) when there was nothing worth folding — +// fewer than the keep-turns floor's worth of complete turns exist yet. +type CompactResult struct { + TurnsFolded int + FirstID string + LastID string + Summary *message.Message +} + +// effectiveKeepTurns resolves CompactOptions.KeepTurns/Config. +// CompactionKeepTurns down to one concrete, floored value. +func (s *Session) effectiveKeepTurns(optKeepTurns int) int { + keep := optKeepTurns + if keep <= 0 { + keep = s.cfg.CompactionKeepTurns + } + if keep <= 0 { + keep = defaultCompactionKeepTurns + } + if keep < minCompactionKeepTurns { + keep = minCompactionKeepTurns + } + return keep +} + +// turnBoundaries returns the indices within history of every message that +// starts a turn: a RoleUser message. A turn runs from one such index up to +// (not including) the next, or end of history — see docs/design/ +// context-compaction.md §2. +func turnBoundaries(history []message.Message) []int { + var starts []int + for i, m := range history { + if m.Role == message.RoleUser { + starts = append(starts, i) + } + } + return starts +} + +// Compact folds a contiguous prefix of whole turns into one synthetic +// summary message, durably, in place. It is the single entry point both the +// automatic trigger (maybeAutoCompact) and the explicit POST +// /session/{id}/compact endpoint funnel through — see docs/design/ +// context-compaction.md §1. +// +// It runs the slow, network-bound summarization call WITHOUT holding s.mu +// (same pattern streamTurn uses via History()), then re-acquires s.mu once +// to splice s.history and persist the compact record in one critical +// section — a concurrent reader of History/Usage/LastUsage sees the pre- or +// post-compaction state, never a half-spliced one. +// +// A result with TurnsFolded == 0 is not an error: fewer than the effective +// keep-turns floor's worth of complete turns exist yet, so there is nothing +// to gain by folding (see §2's minimum-fold rule). Any other failure (the +// summarization call itself errors, or — defense in depth — the computed +// range cannot be found) aborts cleanly: no journal write, no history +// mutation, and an emitted EventCompactionFailed. +func (s *Session) Compact(ctx context.Context, opts CompactOptions) (CompactResult, error) { + history := s.History() + keepTurns := s.effectiveKeepTurns(opts.KeepTurns) + + starts := turnBoundaries(history) + if len(starts) <= keepTurns { + return CompactResult{}, nil + } + foldTurns := len(starts) - keepTurns + foldStart := starts[0] + foldEndExclusive := starts[foldTurns] // first KEPT turn's leading RoleUser message + foldEnd := foldEndExclusive - 1 + + firstID := history[foldStart].ID + lastID := history[foldEnd].ID + + model := opts.Model + if model.IsZero() { + model = s.cfg.CompactionModel + } + if model.IsZero() { + model = s.Model() + } + + summaryText, usage, err := s.runCompactionSummary(ctx, model, history[foldStart:foldEnd+1]) + if err != nil { + s.emit(Event{Type: EventCompactionFailed, Text: err.Error()}) + return CompactResult{}, err + } + + summary := message.Message{ + ID: newID("msg"), + Role: message.RoleUser, + Parts: message.Parts{&message.Text{Text: CompactionSummaryBanner + summaryText}}, + CreatedAt: time.Now().UTC(), + } + + s.mu.Lock() + spliced, err := spliceCompact(s.history, firstID, lastID, summary) + if err != nil { + s.mu.Unlock() + s.emit(Event{Type: EventCompactionFailed, Text: err.Error()}) + return CompactResult{}, err + } + s.history = spliced + // Cumulative usage only (see docs/design/context-compaction.md's "Usage + // accounting"): NEVER touch lastUsage/haveLastUsage here — the + // automatic trigger reads LastUsage as "how large is the next worker + // request", and this small summarization call would mask the very + // pressure that triggered compaction. + s.usage.InputTokens += usage.InputTokens + s.usage.OutputTokens += usage.OutputTokens + s.usage.CacheReadTokens += usage.CacheReadTokens + s.usage.CacheWriteTokens += usage.CacheWriteTokens + s.compactCount++ + s.lastCompactedAt = summary.CreatedAt + s.persistCompactLocked(firstID, lastID, foldTurns, summary, usage) + s.mu.Unlock() + + // Live event surface (§4): the summary flows through the ordinary + // message-event path FIRST, so an events.jsonl tailer receives the + // summary content before it ever sees history.compacted — the durable + // compact record carries the summary inline rather than as a + // recMessage, so without this emission a tailer would hold a dangling + // id for a message it never received. + s.emit(Event{Type: EventMessage, Message: &summary}) + s.emit(Event{ + Type: EventHistoryCompacted, + CompactFirstID: firstID, + CompactLastID: lastID, + CompactTurnsFolded: foldTurns, + CompactSummaryID: summary.ID, + }) + + return CompactResult{ + TurnsFolded: foldTurns, + FirstID: firstID, + LastID: lastID, + Summary: &summary, + }, nil +} + +// runCompactionSummary issues the tool-less summarization call: a request +// built from exactly the folded range's messages (independently +// transcodable — a whole-turns range never has a dangling tool call at +// either edge) plus the dedicated compaction system prompt. Mirrors the +// evaluator shape goal.go's runEvaluator already establishes, but sends the +// folded messages directly rather than a rendered transcript, since (unlike +// the evaluator's cross-cutting judge call) this range is always +// transcodable as-is. +func (s *Session) runCompactionSummary(ctx context.Context, model message.ModelRef, folded []message.Message) (string, provider.Usage, error) { + prov, err := s.cfg.Providers.For(model) + if err != nil { + return "", provider.Usage{}, err + } + req := &provider.Request{ + Model: model, + System: []string{compactionSystemPrompt}, + Messages: append([]message.Message(nil), folded...), + MaxTokens: compactionMaxTokens, + } + stream, err := prov.Stream(ctx, req) + if err != nil { + return "", provider.Usage{}, err + } + defer stream.Close() + + var deltas strings.Builder + var doneText string + var usage provider.Usage + for { + ev, err := stream.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", provider.Usage{}, err + } + switch ev.Type { + case provider.EventTextDelta: + deltas.WriteString(ev.Text) + case provider.EventDone: + usage = ev.Usage + if ev.Message != nil { + doneText = ev.Message.Parts.Text() + } + } + } + text := doneText + if text == "" { + text = deltas.String() + } + if strings.TrimSpace(text) == "" { + return "", provider.Usage{}, errors.New("engine: compaction summary was empty") + } + return text, usage, nil +} + +// spliceCompact replaces history[start..end] (the messages named firstID and +// lastID, inclusive) with summary, returning a fresh slice that never +// aliases history's backing array. Shared by the live Compact path above and +// LoadSession's recCompact replay (see store.go) so the two can never drift +// apart. firstID/lastID not found (in order) within history is corruption — +// an explicit error, never a silent best-effort guess. +func spliceCompact(history []message.Message, firstID, lastID string, summary message.Message) ([]message.Message, error) { + start, end := -1, -1 + for i, m := range history { + if start == -1 && m.ID == firstID { + start = i + } + if start != -1 && m.ID == lastID { + end = i + break + } + } + if start == -1 || end == -1 { + return nil, fmt.Errorf("engine: compact record range [%s, %s] not found in history", firstID, lastID) + } + out := make([]message.Message, 0, len(history)-(end-start+1)+1) + out = append(out, history[:start]...) + out = append(out, summary) + out = append(out, history[end+1:]...) + return out, nil +} + +// maybeAutoCompact is Prompt's automatic-trigger check (see docs/design/ +// context-compaction.md §1): a no-op unless Config.ContextWindowTokens is +// positive (opt-in) and at least one turn has completed. Best-effort: a +// failed or skipped compaction never blocks the caller's real turn — the +// turn simply proceeds uncompacted, at the same risk layer 1's +// context-overflow classification already handles if it actually overflows. +func (s *Session) maybeAutoCompact(ctx context.Context) { + s.mu.Lock() + windowTokens := s.cfg.ContextWindowTokens + threshold := s.cfg.CompactionThreshold + lastUsage := s.lastUsage + haveLastUsage := s.haveLastUsage + onCooldown := s.compactHysteresis + s.mu.Unlock() + + if windowTokens <= 0 || !haveLastUsage { + return + } + if threshold <= 0 { + threshold = defaultCompactionThreshold + } + // The prompt occupies the context window as the SUM of all three + // input components. Harness injects cache_control by default, so on a + // warm session the Anthropic adapter reports most of the prompt in + // CacheReadTokens (new prefix growth in CacheWriteTokens) while + // InputTokens is only the uncached tail — counting InputTokens alone + // meant auto-compaction never fired in exactly the long-cached-session + // shape it exists for. + promptTokens := lastUsage.InputTokens + lastUsage.CacheReadTokens + lastUsage.CacheWriteTokens + over := float64(promptTokens) >= threshold*float64(windowTokens) + if !over { + // Churn-guard reset: LastUsage has dipped below the threshold at + // least once since the last automatic compaction, so a future + // crossing is allowed to trigger again. + if onCooldown { + s.mu.Lock() + s.compactHysteresis = false + s.mu.Unlock() + } + return + } + if onCooldown { + // Churn guard (§2): still over threshold since the last automatic + // compaction. The pressure must live in the kept region (a single + // giant tool result) — folding the prefix again cannot relieve it, + // so do not re-fire every turn. + return + } + + res, err := s.Compact(ctx, CompactOptions{}) + if err != nil { + // Best-effort: EventCompactionFailed already emitted inside + // Compact. The turn proceeds uncompacted. + return + } + if res.TurnsFolded > 0 { + s.mu.Lock() + s.compactHysteresis = true + s.mu.Unlock() + } +} diff --git a/engine/compact_cached_test.go b/engine/compact_cached_test.go new file mode 100644 index 0000000..a8617ae --- /dev/null +++ b/engine/compact_cached_test.go @@ -0,0 +1,43 @@ +package engine + +import ( + "testing" + + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" +) + +// TestMaybeAutoCompactCountsCachedPromptTokens encodes the PR#74 review +// finding, shaped like the original 205,102-token incident UNDER PROMPT +// CACHING: harness injects cache_control by default, so on a warm session +// the Anthropic adapter reports the bulk of the prompt in CacheReadTokens +// (and new prefix growth in CacheWriteTokens) while InputTokens stays +// small. A threshold check reading InputTokens alone never fires in +// exactly the production shape auto-compaction exists for. The prompt +// size is the SUM of the three input components. +func TestMaybeAutoCompactCountsCachedPromptTokens(t *testing.T) { + // The incident, cached: ~197k of prompt in cache reads/writes, a few + // thousand uncached. Window 200k, default threshold — must trigger. + warmOver := provider.Usage{InputTokens: 3_102, CacheReadTokens: 190_000, CacheWriteTokens: 12_000} + small := provider.Usage{InputTokens: 100} + + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("t1", small), // call 1: no lastUsage yet + compactTurn("t2", warmOver), // call 2: lastUsage(t1)=small, no trigger + compactSummaryTurn("gist", provider.Usage{InputTokens: 5}), // must be consumed by the trigger before call 3 + compactTurn("t3", small), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + ContextWindowTokens: 200_000, + CompactionKeepTurns: 1, + }) + runTurns(t, s, 3) + + if got := s.CompactionCount(); got != 1 { + t.Fatalf("CompactionCount = %d, want 1: a warm-cached %d-token prompt (input %d + cache read %d + cache write %d) crossed the threshold but did not trigger", + got, warmOver.InputTokens+warmOver.CacheReadTokens+warmOver.CacheWriteTokens, + warmOver.InputTokens, warmOver.CacheReadTokens, warmOver.CacheWriteTokens) + } +} diff --git a/engine/compact_test.go b/engine/compact_test.go new file mode 100644 index 0000000..bc00451 --- /dev/null +++ b/engine/compact_test.go @@ -0,0 +1,643 @@ +package engine + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" +) + +// compactTurnSeq gives each compactTurn call in a test a distinct assistant +// message ID: asstTurn's shared "msg_a" constant is fine for tests that +// never compare IDs across turns, but compaction's FirstID/LastID and its +// ID-based splice (see spliceCompact) need turns to be distinguishable by +// ID, exactly as production message IDs always are (every message gets a +// fresh newID()). +var compactTurnSeq int + +// compactTurn builds a scripted worker-turn assistant reply carrying usage, +// with a fresh, unique message ID (see compactTurnSeq). +func compactTurn(text string, usage provider.Usage) []provider.Event { + compactTurnSeq++ + msg := &message.Message{ID: fmt.Sprintf("msg_asst_%d", compactTurnSeq), Role: message.RoleAssistant, Parts: message.Parts{&message.Text{Text: text}}} + ev := provider.Event{Type: provider.EventDone, Message: msg, StopReason: provider.StopEndTurn, Usage: usage} + return []provider.Event{ev} +} + +// compactSummaryTurn builds a scripted reply for the tool-less summarization +// call Session.Compact issues: a plain text assistant message, no tool +// calls, ending the stream via EventDone. +func compactSummaryTurn(text string, usage provider.Usage) []provider.Event { + return compactTurn(text, usage) +} + +// runTurns drives n ordinary Prompt calls against s, failing the test on any +// error. +func runTurns(t *testing.T, s *Session, n int) { + t.Helper() + for i := 0; i < n; i++ { + if _, err := s.Prompt(context.Background(), "go"); err != nil { + t.Fatalf("turn %d: %v", i+1, err) + } + } +} + +// TestCompactFoldsOldestPrefixKeepsRecentTurns is the red-first behavior +// test for the core mechanism (docs/design/context-compaction.md §2): a +// contiguous prefix of whole turns folds into one summary message, the most +// recent keep_turns turns survive verbatim, and FirstID/LastID name exactly +// the folded range's boundary messages. +func TestCompactFoldsOldestPrefixKeepsRecentTurns(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10, OutputTokens: 5}), + compactTurn("two", provider.Usage{InputTokens: 20, OutputTokens: 5}), + compactTurn("three", provider.Usage{InputTokens: 30, OutputTokens: 5}), + compactSummaryTurn("SUMMARY", provider.Usage{InputTokens: 40, OutputTokens: 8}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 3) + + before := s.History() + if len(before) != 6 { + t.Fatalf("history before compact = %d messages, want 6 (3 turns x 2)", len(before)) + } + wantFirstID := before[0].ID // turn 1's leading RoleUser message + wantLastID := before[3].ID // last message before turn 3's leading RoleUser message + + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err != nil { + t.Fatalf("Compact: %v", err) + } + if res.TurnsFolded != 2 { + t.Fatalf("TurnsFolded = %d, want 2", res.TurnsFolded) + } + if res.FirstID != wantFirstID { + t.Errorf("FirstID = %q, want %q", res.FirstID, wantFirstID) + } + if res.LastID != wantLastID { + t.Errorf("LastID = %q, want %q", res.LastID, wantLastID) + } + + after := s.History() + if len(after) != 3 { + t.Fatalf("history after compact = %d messages, want 3 (summary + kept turn 3)", len(after)) + } + if after[0].Role != message.RoleUser { + t.Errorf("after[0].Role = %s, want RoleUser (summary)", after[0].Role) + } + if after[0].ID != res.Summary.ID { + t.Errorf("after[0].ID = %q, want summary id %q", after[0].ID, res.Summary.ID) + } + if got := after[0].Parts.Text(); got == "" { + t.Error("summary message has no text") + } + // Turn 3 survives verbatim. + if after[1].Parts.Text() != "go" && after[1].Role != message.RoleUser { + t.Errorf("after[1] = %+v, want turn 3's user message", after[1]) + } + if after[2].Parts.Text() != "three" { + t.Errorf("after[2] text = %q, want %q (turn 3's assistant reply)", after[2].Parts.Text(), "three") + } +} + +// TestCompactSummaryBannerMarksSyntheticOrigin asserts the summary text +// carries the visible synthesized-and-marked banner, mirroring +// message.SyntheticOrphanResultText's spirit — a transcript reader can never +// mistake it for something the human actually typed. +func TestCompactSummaryBannerMarksSyntheticOrigin(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 10}), + compactSummaryTurn("the gist", provider.Usage{InputTokens: 5}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 2) + + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err != nil { + t.Fatal(err) + } + got := res.Summary.Parts.Text() + if got != CompactionSummaryBanner+"the gist" { + t.Errorf("summary text = %q, want banner-prefixed", got) + } +} + +// TestCompactNoopWhenNotEnoughTurns is the red-first test for §2's minimum- +// fold rule: fewer than keep_turns complete turns exist yet, so compaction +// is a no-op (turns_folded: 0, not an error) — and, crucially, it never +// calls the provider at all (nothing to summarize). +func TestCompactNoopWhenNotEnoughTurns(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 1) + + before := s.History() + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 2}) + if err != nil { + t.Fatalf("Compact: %v", err) + } + if res.TurnsFolded != 0 { + t.Errorf("TurnsFolded = %d, want 0", res.TurnsFolded) + } + if len(prov.requests) != 1 { + t.Errorf("provider calls = %d, want 1 (only the worker turn — no summarization call)", len(prov.requests)) + } + after := s.History() + if len(after) != len(before) { + t.Errorf("history mutated on a no-op compaction: before=%d after=%d", len(before), len(after)) + } +} + +// TestCompactKeepTurnsFloor is the red-first test for the hard floor on +// keep_turns (docs/design/context-compaction.md §1): the most recent turn +// is never foldable, so even an aggressive KeepTurns request always leaves +// at least one whole turn verbatim. +func TestCompactKeepTurnsFloor(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 10}), + compactSummaryTurn("gist", provider.Usage{InputTokens: 5}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 2) + + // KeepTurns: 1 is the minimum valid value (server-side validation + // rejects <= 0 before ever reaching here — see server/handlers.go); the + // engine must honor it exactly, never defaulting it away. + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err != nil { + t.Fatal(err) + } + if res.TurnsFolded != 1 { + t.Fatalf("TurnsFolded = %d, want 1", res.TurnsFolded) + } + after := s.History() + if len(after) != 3 { // summary + kept turn's user+assistant + t.Fatalf("history after compact = %d messages, want 3", len(after)) + } +} + +// TestCompactUsageAccountingCumulativeOnlyNotLastUsage is the red-first test +// for §2's "Usage accounting": the summarization call's tokens are real +// spend and must be added to cumulative Usage(), but must NEVER overwrite +// LastUsage() — the automatic trigger reads LastUsage as "how large is the +// next worker request", and a small summarization call would mask the very +// pressure that triggered compaction. +func TestCompactUsageAccountingCumulativeOnlyNotLastUsage(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 100, OutputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 200, OutputTokens: 10}), + compactSummaryTurn("gist", provider.Usage{InputTokens: 7, OutputTokens: 3}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 2) + + wantUsageBefore := provider.Usage{InputTokens: 300, OutputTokens: 20} + if got := s.Usage(); got != wantUsageBefore { + t.Fatalf("Usage before compact = %+v, want %+v", got, wantUsageBefore) + } + lastBefore, _ := s.LastUsage() + + if _, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}); err != nil { + t.Fatal(err) + } + + wantUsageAfter := provider.Usage{InputTokens: 307, OutputTokens: 23} + if got := s.Usage(); got != wantUsageAfter { + t.Errorf("Usage after compact = %+v, want %+v (summarization spend added)", got, wantUsageAfter) + } + lastAfter, ok := s.LastUsage() + if !ok { + t.Fatal("LastUsage not ok after compact") + } + if lastAfter != lastBefore { + t.Errorf("LastUsage changed by compaction: before=%+v after=%+v, want unchanged", lastBefore, lastAfter) + } +} + +// TestCompactFailureNoJournalNoMutation is the red-first test for §2's +// "Failure handling": when the summarization call itself errors, compaction +// aborts cleanly — no history mutation, no journal write, and an emitted +// EventCompactionFailed. +func TestCompactFailureNoJournalNoMutation(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 10}), + // No third scripted turn: the summarization call (the 3rd + // prov.Stream call) exhausts p.turns and returns io.ErrUnexpectedEOF + // (see scriptedProvider.Stream). + }} + dir := t.TempDir() + var evs []Event + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + SessionDir: dir, + OnEvent: func(ev Event) { evs = append(evs, ev) }, + }) + runTurns(t, s, 2) + before := s.History() + beforeCompactCount := s.CompactionCount() + + _, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err == nil { + t.Fatal("Compact succeeded, want an error (provider call exhausted)") + } + + after := s.History() + if len(after) != len(before) { + t.Errorf("history mutated on a failed compaction: before=%d after=%d", len(before), len(after)) + } + if got := s.CompactionCount(); got != beforeCompactCount { + t.Errorf("CompactionCount = %d, want unchanged at %d", got, beforeCompactCount) + } + + var failed int + for _, ev := range evs { + if ev.Type == EventCompactionFailed { + failed++ + } + } + if failed != 1 { + t.Errorf("EventCompactionFailed count = %d, want 1", failed) + } + + // Reload: the log must show no compact record — a torn/aborted + // compaction is indistinguishable from "never started" (§3 "Crash + // discipline"). + loaded, err := LoadSession(s.cfg, s.ID) + if err != nil { + t.Fatal(err) + } + if got := loaded.CompactionCount(); got != 0 { + t.Errorf("reloaded CompactionCount = %d, want 0 (failed compaction never journaled)", got) + } + if len(loaded.History()) != len(before) { + t.Errorf("reloaded history = %d messages, want %d (unchanged)", len(loaded.History()), len(before)) + } +} + +// TestCompactSummaryFlowsThroughEventMessageBeforeHistoryCompacted is the +// red-first test for §4's "Live event surface": a successful compaction +// emits the summary via the ordinary EventMessage path FIRST, then +// EventHistoryCompacted — never the other order, or an events.jsonl tailer +// would hold a dangling summary_id it never received a message for. +func TestCompactSummaryFlowsThroughEventMessageBeforeHistoryCompacted(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 10}), + compactSummaryTurn("gist", provider.Usage{InputTokens: 5}), + }} + var evs []Event + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + OnEvent: func(ev Event) { evs = append(evs, ev) }, + }) + runTurns(t, s, 2) + evs = nil // discard the two ordinary turns' events + + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err != nil { + t.Fatal(err) + } + + var messageIdx, compactedIdx = -1, -1 + for i, ev := range evs { + switch ev.Type { + case EventMessage: + if ev.Message != nil && ev.Message.ID == res.Summary.ID { + messageIdx = i + } + case EventHistoryCompacted: + compactedIdx = i + } + } + if messageIdx == -1 { + t.Fatal("no EventMessage carrying the summary was emitted") + } + if compactedIdx == -1 { + t.Fatal("no EventHistoryCompacted was emitted") + } + if messageIdx >= compactedIdx { + t.Errorf("EventMessage(summary) at %d, EventHistoryCompacted at %d; want message strictly before", messageIdx, compactedIdx) + } + last := evs[compactedIdx] + if last.CompactFirstID != res.FirstID || last.CompactLastID != res.LastID || + last.CompactTurnsFolded != res.TurnsFolded || last.CompactSummaryID != res.Summary.ID { + t.Errorf("EventHistoryCompacted = %+v, want it to carry the compact result", last) + } +} + +// TestCompactSurvivesReload is the red-first restart test for §2's +// "LoadSession replay": a reloaded session replays the compact record and +// the trimmed history — the summary lands exactly where it did live, and +// cumulative usage (including the summarization spend) survives, but +// LastUsage does not pick up the summarization call's tiny usage. +func TestCompactSurvivesReload(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 100, OutputTokens: 10}), + compactTurn("two", provider.Usage{InputTokens: 200, OutputTokens: 10}), + compactTurn("three", provider.Usage{InputTokens: 300, OutputTokens: 10}), + compactSummaryTurn("the gist of turns one and two", provider.Usage{InputTokens: 9, OutputTokens: 4}), + }} + dir := t.TempDir() + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + SessionDir: dir, + }) + runTurns(t, s, 3) + + res, err := s.Compact(context.Background(), CompactOptions{KeepTurns: 1}) + if err != nil { + t.Fatal(err) + } + wantHistory := s.History() + wantUsage := s.Usage() + wantLast, _ := s.LastUsage() + wantCount := s.CompactionCount() + wantLastCompactedAt := s.LastCompactedAt() + + loaded, err := LoadSession(s.cfg, s.ID) + if err != nil { + t.Fatal(err) + } + gotHistory := loaded.History() + if len(gotHistory) != len(wantHistory) { + t.Fatalf("reloaded history = %d messages, want %d", len(gotHistory), len(wantHistory)) + } + for i := range wantHistory { + if gotHistory[i].ID != wantHistory[i].ID || gotHistory[i].Role != wantHistory[i].Role || + gotHistory[i].Parts.Text() != wantHistory[i].Parts.Text() { + t.Errorf("reloaded history[%d] = %+v, want %+v", i, gotHistory[i], wantHistory[i]) + } + } + if got := loaded.Usage(); got != wantUsage { + t.Errorf("reloaded Usage = %+v, want %+v", got, wantUsage) + } + last, ok := loaded.LastUsage() + if !ok || last != wantLast { + t.Errorf("reloaded LastUsage = %+v (ok=%v), want %+v", last, ok, wantLast) + } + if got := loaded.CompactionCount(); got != wantCount { + t.Errorf("reloaded CompactionCount = %d, want %d", got, wantCount) + } + if !loaded.LastCompactedAt().Equal(wantLastCompactedAt) { + t.Errorf("reloaded LastCompactedAt = %v, want %v", loaded.LastCompactedAt(), wantLastCompactedAt) + } + if res.TurnsFolded != 2 { + t.Fatalf("sanity: TurnsFolded = %d, want 2", res.TurnsFolded) + } + + // A post-compaction session must restart cleanly and keep working: a + // further Prompt on the reloaded session must succeed. + prov.turns = append(prov.turns, compactTurn("four", provider.Usage{InputTokens: 50, OutputTokens: 5})) + if _, err := loaded.Prompt(context.Background(), "keep going"); err != nil { + t.Fatalf("Prompt on reloaded post-compaction session: %v", err) + } +} + +// TestCompactCorruptRangeIsLoadError is the red-first test for §2's "Not +// found is treated as corruption" rule: a compact record naming a +// first_id/last_id pair that is not present in the accumulated history is +// an explicit LoadSession error, never a silent best-effort guess. +func TestCompactCorruptRangeIsLoadError(t *testing.T) { + dir := t.TempDir() + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("one", provider.Usage{InputTokens: 10}), + }} + cfg := Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + SessionDir: dir, + } + s := NewSession(cfg) + if _, err := s.Prompt(context.Background(), "go"); err != nil { + t.Fatal(err) + } + s.mu.Lock() + s.persistCompactLocked("msg_does_not_exist", "msg_also_missing", 1, message.Message{ + ID: newID("msg"), Role: message.RoleUser, Parts: message.Parts{&message.Text{Text: "x"}}, + }, provider.Usage{}) + s.mu.Unlock() + + if _, err := LoadSession(cfg, s.ID); err == nil { + t.Fatal("LoadSession succeeded on a corrupt compact record range, want an error") + } +} + +// TestMaybeAutoCompactTriggersAndHysteresisPreventsThrash is the red-first +// test for §1's automatic trigger and §2's churn-guard hysteresis: crossing +// the threshold fires exactly one compaction; a second consecutive +// over-threshold turn (no intervening dip) does NOT re-fire; a dip below +// the threshold clears the guard so a later crossing can fire again. +func TestMaybeAutoCompactTriggersAndHysteresisPreventsThrash(t *testing.T) { + over := provider.Usage{InputTokens: 900} + under := provider.Usage{InputTokens: 100} + + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("t1", under), // call 1: no lastUsage yet, no auto-compact possible + compactTurn("t2", over), // call 2: lastUsage(t1)=under, no trigger + compactSummaryTurn("gist-1", provider.Usage{InputTokens: 5}), // triggered before call 3 (lastUsage(t2)=over) + compactTurn("t3", over), // call 3's own turn (post first compaction) + compactTurn("t4", under), // call 4: lastUsage(t3)=over but on cooldown, no trigger + compactTurn("t5", over), // call 5: lastUsage(t4)=under, cooldown clears, no trigger (not over) + compactSummaryTurn("gist-2", provider.Usage{InputTokens: 5}), // triggered before call 6 (lastUsage(t5)=over) + compactTurn("t6", under), // call 6's own turn (post second compaction) + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + ContextWindowTokens: 1000, + CompactionKeepTurns: 1, + }) + runTurns(t, s, 6) + + if got := s.CompactionCount(); got != 2 { + t.Fatalf("CompactionCount = %d, want exactly 2 (hysteresis must have suppressed a third)", got) + } + if len(prov.requests) != 8 { + t.Fatalf("provider calls = %d, want 8 (6 worker turns + 2 compaction summaries)", len(prov.requests)) + } +} + +// TestMaybeAutoCompactDisabledByDefault is the red-first test for the +// opt-in gate: Config.ContextWindowTokens's zero value (a fresh Config) +// disables automatic compaction entirely, so a huge LastUsage never +// triggers it — no existing deployment changes behavior by upgrading (§5 +// "Non-goals"). +func TestMaybeAutoCompactDisabledByDefault(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("t1", provider.Usage{InputTokens: 999_999}), + compactTurn("t2", provider.Usage{InputTokens: 999_999}), + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + }) + runTurns(t, s, 2) + if got := s.CompactionCount(); got != 0 { + t.Errorf("CompactionCount = %d, want 0 (ContextWindowTokens unset)", got) + } + if len(prov.requests) != 2 { + t.Errorf("provider calls = %d, want 2 (no compaction summary calls)", len(prov.requests)) + } +} + +// TestIncidentRecoverableByCompaction is the red-first regression test for +// the production incident: a goal session died at 205102 tokens > 200000 +// maximum ("invalid_request_error: prompt is too long") and was +// unrecoverable afterward. With ContextWindowTokens configured, the +// automatic trigger must fold history BEFORE the next request would repeat +// that identical, deterministic failure — turning the incident's shape into +// a recoverable one instead of a dead session. +func TestIncidentRecoverableByCompaction(t *testing.T) { + // Three prior worker turns, the last one landing at the incident's exact + // input-token count, followed by the automatic compaction's own + // summarization call, then a worker turn that must now succeed instead + // of repeating the "prompt is too long" failure. + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactTurn("t1", provider.Usage{InputTokens: 50_000, OutputTokens: 500}), + compactTurn("t2", provider.Usage{InputTokens: 120_000, OutputTokens: 500}), + compactTurn("t3", provider.Usage{InputTokens: 205_102, OutputTokens: 500}), // the incident's exact figure + compactSummaryTurn("summary of the first two turns", provider.Usage{InputTokens: 4_000, OutputTokens: 200}), + compactTurn("t4", provider.Usage{InputTokens: 30_000, OutputTokens: 500}), // succeeds: history was trimmed first + }} + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + ContextWindowTokens: 200_000, // the incident's exact maximum + CompactionKeepTurns: 1, + }) + runTurns(t, s, 3) + + last, ok := s.LastUsage() + if !ok || last.InputTokens != 205_102 { + t.Fatalf("LastUsage = %+v (ok=%v), want the incident's 205102 input tokens", last, ok) + } + if got := s.CompactionCount(); got != 0 { + t.Fatalf("CompactionCount = %d before the 4th call, want 0", got) + } + + // Pre-fix, this 4th call would resend the full, now-over-limit history + // and die identically ("prompt is too long"). Post-fix, maybeAutoCompact + // folds the oldest turns first, so the request this turn actually sends + // is far smaller — the incident's exact failure mode never recurs. + if _, err := s.Prompt(context.Background(), "keep going"); err != nil { + t.Fatalf("Prompt on a session over the context-window threshold: %v (must be recoverable by compaction, not fatal)", err) + } + if got := s.CompactionCount(); got != 1 { + t.Fatalf("CompactionCount after the 4th call = %d, want 1 (automatic compaction must have fired)", got) + } + finalReq := prov.requests[len(prov.requests)-1] + if len(finalReq.Messages) >= 6 { // pre-compaction full history would have been >= 6 messages (3 turns) + t.Errorf("final request carried %d messages, want a trimmed history (compaction folded the old turns)", len(finalReq.Messages)) + } +} + +// goalCompactProvider serves the goal loop's worker turns, its independent +// tool-less evaluator, AND compaction's own tool-less summarization call — +// classifying by System content (the compaction system prompt is a unique +// marker no evaluator request ever carries) and, failing that, by whether +// Tools is empty (the evaluator's request, per goalProvider in +// goal_test.go). This is what "no goal.go changes needed" (docs/design/ +// context-compaction.md §1) means in practice: PursueGoal drives everything +// through Prompt, so the automatic trigger fires mid-goal-loop with no +// special-casing at all — this test proves it end to end. +type goalCompactProvider struct { + worker, eval, summary [][]provider.Event + wi, ei, si int + requests []*provider.Request +} + +func (p *goalCompactProvider) Name() string { return "test" } + +func (p *goalCompactProvider) Stream(_ context.Context, req *provider.Request) (provider.Stream, error) { + p.requests = append(p.requests, req) + if len(req.System) == 1 && req.System[0] == compactionSystemPrompt { + if p.si >= len(p.summary) { + return nil, io.ErrUnexpectedEOF + } + ev := p.summary[p.si] + p.si++ + return &scriptedStream{events: ev}, nil + } + if len(req.Tools) == 0 { + if p.ei >= len(p.eval) { + return &scriptedStream{}, nil + } + ev := p.eval[p.ei] + p.ei++ + return &scriptedStream{events: ev}, nil + } + if p.wi >= len(p.worker) { + return &scriptedStream{}, nil + } + ev := p.worker[p.wi] + p.wi++ + return &scriptedStream{events: ev}, nil +} + +// TestPursueGoalAutoCompactsMidLoop is the red-first test for §1's "no +// separate scheduler, no goal.go changes needed": a goal loop, driven +// entirely through the ordinary Prompt path, auto-compacts mid-loop exactly +// like a bare prompt_async session would, and still reaches its goal +// afterward. +func TestPursueGoalAutoCompactsMidLoop(t *testing.T) { + prov := &goalCompactProvider{ + worker: [][]provider.Event{ + compactTurn("working turn 1", provider.Usage{InputTokens: 100}), + compactTurn("working turn 2", provider.Usage{InputTokens: 900}), // over threshold + compactTurn("working turn 3", provider.Usage{InputTokens: 100}), // proceeds post-compaction + }, + eval: [][]provider.Event{ + evalTurn("NOT MET: keep going"), + evalTurn("NOT MET: still going"), + evalTurn("MET: done"), + }, + summary: [][]provider.Event{ + compactSummaryTurn("gist of turn 1", provider.Usage{InputTokens: 20}), + }, + } + s := NewSession(Config{ + Providers: provider.Registry{"test": prov}, + Model: message.ModelRef{Provider: "test", Model: "m1"}, + Instructions: &InstructionsConfig{Disabled: true}, + SkillsDirs: []string{}, + ContextWindowTokens: 1000, + CompactionKeepTurns: 1, + }) + + res, err := s.PursueGoal(context.Background(), "finish the thing", GoalOptions{Evaluator: evalModel}) + if err != nil { + t.Fatalf("PursueGoal: %v", err) + } + if !res.Achieved { + t.Fatalf("PursueGoal result = %+v, want Achieved", res) + } + if got := s.CompactionCount(); got != 1 { + t.Fatalf("CompactionCount = %d, want exactly 1 (mid-loop automatic compaction)", got) + } +} diff --git a/engine/engine.go b/engine/engine.go index c4bfc6a..38c83f4 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -79,6 +79,18 @@ type Event struct { GoalRetryable bool `json:"goal_retryable,omitempty"` GoalRetryableClass string `json:"goal_retryable_class,omitempty"` GoalWaiting bool `json:"goal_waiting,omitempty"` + + // Compaction fields (see compact.go and docs/design/context- + // compaction.md §4 "Live event surface"). Carried on + // EventHistoryCompacted: CompactFirstID/CompactLastID name the folded + // range, CompactTurnsFolded is the fold count, and CompactSummaryID + // names the summary message (already delivered via a preceding + // EventMessage — see Session.Compact). EventCompactionFailed carries + // only Text (the error detail). + CompactFirstID string `json:"compact_first_id,omitempty"` + CompactLastID string `json:"compact_last_id,omitempty"` + CompactTurnsFolded int `json:"compact_turns_folded,omitempty"` + CompactSummaryID string `json:"compact_summary_id,omitempty"` } // Event types. @@ -184,6 +196,28 @@ type Config struct { // command (an apt-get/npm install storm is the real-world trigger) can // otherwise dump megabytes into a single message and poison the session. BashOutputCap int + + // ContextWindowTokens is the model's context window size, in tokens. + // Zero (the default, a fresh Config's zero value) disables automatic + // compaction entirely: the engine has no built-in per-model table, so + // this is opt-in. When positive, Prompt checks LastUsage against + // ContextWindowTokens * CompactionThreshold at the top of every call + // and compacts first if over. See docs/design/context-compaction.md. + ContextWindowTokens int + // CompactionThreshold is the fraction of ContextWindowTokens at which + // automatic compaction triggers. Zero defaults to 0.8, mirroring + // newSession's existing zero-fills-a-default pattern for BashTimeout. + CompactionThreshold float64 + // CompactionKeepTurns is how many of the most recent turns automatic + // (and, unless overridden per call, explicit) compaction always keeps + // verbatim. Zero defaults to 2. The effective value can never compact + // below 1 (see Session.Compact). + CompactionKeepTurns int + // CompactionModel overrides the model used for the compaction summary + // call. Zero (the default) uses the session's own current model at the + // time Compact runs — unlike GoalOptions.Evaluator, summarization needs + // competence, not independence. + CompactionModel message.ModelRef } // Session is one conversation: an in-memory history plus the agent loop. @@ -262,6 +296,28 @@ type Session struct { // any tool before failing — a retry re-issues the whole directive, which // is unsafe to do blindly once a tool has already run. Guarded by mu. toolExecCount int + + // compactHysteresis is the churn-guard flag (see docs/design/ + // context-compaction.md §2): set true the moment an AUTOMATIC + // compaction actually folds something, cleared the next time + // LastUsage().InputTokens is observed below the trigger threshold. + // While true, the automatic trigger does not fire again — folding an + // ever-shrinking prefix cannot relieve pressure that lives in the KEPT + // region (a single giant tool result), so re-firing every turn would + // just burn summarization round-trips. Deliberately NOT persisted: a + // reload re-evaluates from scratch (see LoadSession), and the worst + // post-reload cost is one extra summarization attempt. The explicit + // /compact path (Compact called directly, not via maybeAutoCompact) + // never reads or sets this — an operator override always runs. + // Guarded by mu. + compactHysteresis bool + + // compactCount/lastCompactedAt track how many times this session has + // been compacted and when the most recent one landed — durable via the + // compact journal record (see store.go), so GET /session can show a UI + // that compaction happened even after a restart. Guarded by mu. + compactCount int + lastCompactedAt time.Time } // NewSession creates a session. Nothing touches the network, spawns @@ -395,6 +451,25 @@ func (s *Session) LastActivityAt() time.Time { return s.createdAt } +// CompactionCount returns how many times this session has been compacted +// (live or replayed from its log — see LoadSession's recCompact case), and +// LastCompactedAt returns when the most recent one landed (the zero Time if +// never). Together they are what GET /session surfaces so a UI can tell +// compaction happened. See docs/design/context-compaction.md. +func (s *Session) CompactionCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.compactCount +} + +// LastCompactedAt returns the timestamp of the most recent compaction, or +// the zero Time if this session has never been compacted. +func (s *Session) LastCompactedAt() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastCompactedAt +} + // toolExecutions returns the current tool-execution counter (see // toolExecCount). It only ever increases, and only when a tool actually // runs, never when one is blocked by tool.execute.before. @@ -558,6 +633,15 @@ func (s *Session) Prompt(ctx context.Context, text string) (*message.Message, er s.emitSessionError(err) return nil, err } + // Automatic compaction check (docs/design/context-compaction.md §1): + // runs on every call, bare or goal-loop-driven alike, since PursueGoal + // drives everything through Prompt. Deliberately BEFORE the incoming + // user message is appended below: a turn boundary always falls on a + // completed-turn edge, the summary never has to account for a prompt + // that hasn't been answered yet, and the just-arrived message can never + // be folded into its own summary. Best-effort: a failed or skipped + // compaction never blocks the real turn (see maybeAutoCompact). + s.maybeAutoCompact(ctx) s.append(message.Message{ ID: newID("msg"), Role: message.RoleUser, diff --git a/engine/store.go b/engine/store.go index 0b0a30f..5bbdd87 100644 --- a/engine/store.go +++ b/engine/store.go @@ -35,6 +35,11 @@ const ( recGoalStalled = "goal.stalled" recGoalAchieved = "goal.achieved" recGoalCleared = "goal.cleared" + // recCompact is the compaction record (see compact.go and docs/design/ + // context-compaction.md §2 "Journal shape"): one per successful + // Session.Compact call, carrying the full summary message inline (not + // a separate recMessage) and the summarization call's own Usage. + recCompact = "compact" ) // record is one line of a session log file. @@ -64,7 +69,32 @@ type record struct { // record's Usage back into cumulative usage and keeps the last one seen // (see issue #62 layer 2) — the log carries no separate cumulative- // usage record to replay instead. + // + // On a recCompact record, Usage instead carries the SUMMARIZATION + // call's own spend (see compact.go's Session.Compact and docs/design/ + // context-compaction.md's "Usage accounting"): LoadSession's replay + // adds it into cumulative usage ONLY, never into lastUsage/ + // haveLastUsage — unlike recMessage replay, which sets both. A + // reloaded session must not report the small summarization call as its + // "last request size", or the automatic trigger's re-fire check would + // misread the session as small right after a reload. Usage *provider.Usage `json:"usage,omitempty"` + // Compact carries a recCompact record's payload (see compactRecord). + // nil on every other record type. + Compact *compactRecord `json:"compact,omitempty"` +} + +// compactRecord carries the durable payload of a "compact" record (see +// compact.go's Session.Compact and docs/design/context-compaction.md §2 +// "Journal shape"). Summary is the full message.Message to splice in, +// carried inline — not a lightweight marker followed by a separate +// recMessage — so a crash between two records can never leave a dangling +// reference (see §3 "Crash discipline"). +type compactRecord struct { + FirstID string `json:"first_id"` + LastID string `json:"last_id"` + TurnsFolded int `json:"turns_folded"` + Summary message.Message `json:"summary"` } // goalRecord carries the durable payload of a goal.* record (see goal.go). @@ -191,6 +221,35 @@ func (s *Session) persistGoalLocked(recType string, g goalRecord) { } } +// persistCompactLocked appends a compact record to the session log: one +// json.Marshal, one Write call, exactly like every other record (see +// docs/design/context-compaction.md §3 "Crash discipline" — a torn write +// degrades to "compaction never happened", never a partially-spliced or +// ambiguous history). Caller holds s.mu and has already spliced s.history. +func (s *Session) persistCompactLocked(firstID, lastID string, turnsFolded int, summary message.Message, usage provider.Usage) { + if s.cfg.SessionDir == "" { + return + } + if err := s.ensureLog(); err != nil { + s.lastPersistErr = err + return + } + rec := record{ + Type: recCompact, + CreatedAt: summary.CreatedAt, + Usage: &usage, + Compact: &compactRecord{ + FirstID: firstID, + LastID: lastID, + TurnsFolded: turnsFolded, + Summary: summary, + }, + } + if err := s.writeRecord(rec); err != nil { + s.lastPersistErr = err + } +} + // ensureLog opens the session log, creating the directory and file — and // writing the header — on first use. Caller holds s.mu. func (s *Session) ensureLog() error { @@ -345,6 +404,37 @@ func LoadSession(cfg Config, id string) (*Session, error) { // reset). A goal.stalled record never changes goalActive by // itself — either a later goal.stalled retries, or a later // goal.cleared/goal.eval settles it, both handled above. + case recCompact: + // See docs/design/context-compaction.md §2 "LoadSession + // replay": find FirstID/LastID within s.history accumulated so + // far (guaranteed present, in order, since a compact record can + // only be written chronologically after those messages were + // themselves durably appended) and splice — the identical + // function the live path uses (spliceCompact, compact.go), so + // the two can never drift apart. Not found is corruption, an + // explicit error, never a silent best-effort guess. + if rec.Compact == nil { + return fmt.Errorf("compact record without payload at line %d", line) + } + spliced, err := spliceCompact(s.history, rec.Compact.FirstID, rec.Compact.LastID, rec.Compact.Summary) + if err != nil { + return fmt.Errorf("%w at line %d", err, line) + } + s.history = spliced + s.compactCount++ + s.lastCompactedAt = rec.CreatedAt + // Cumulative usage ONLY (see record.Usage's doc comment above + // and the "Usage accounting" section of the design doc): + // lastUsage/haveLastUsage must never be touched by a compact + // record's usage, or a reload would report the small + // summarization call as the session's "last request size" and + // defeat the automatic trigger's re-fire check. + if rec.Usage != nil { + s.usage.InputTokens += rec.Usage.InputTokens + s.usage.OutputTokens += rec.Usage.OutputTokens + s.usage.CacheReadTokens += rec.Usage.CacheReadTokens + s.usage.CacheWriteTokens += rec.Usage.CacheWriteTokens + } } return nil }) diff --git a/provider/anthropic/transcode_test.go b/provider/anthropic/transcode_test.go index ad68129..f744cef 100644 --- a/provider/anthropic/transcode_test.go +++ b/provider/anthropic/transcode_test.go @@ -370,6 +370,50 @@ func assertToolUseFollowedByResult(t *testing.T, out *apiRequest, id string) { t.Fatalf("tool_use %q not found in transcoded request at all", id) } +// TestTranscodeCompactionDoubleRoleUserMerges is the red-first test for the +// compaction splice shape docs/design/context-compaction.md's §2 calls out +// as load-bearing on existing transcoder behavior, not luck: a successful +// compaction (engine/compact.go's Session.Compact) leaves history opening +// with two adjacent RoleUser messages — the synthesized summary, then the +// first kept turn's user prompt — and this adapter's alternation handling +// must merge them into a single wire "user" message rather than producing +// two consecutive user turns the API would reject. An implementer changing +// the summary's role or this merge logic must re-check this pairing. +func TestTranscodeCompactionDoubleRoleUserMerges(t *testing.T) { + summaryText := "[compacted summary of earlier conversation]\n\nthe gist of turns one and two" + out := mustTranscode(t, baseRequest( + // The synthesized compaction summary: an ordinary RoleUser message, + // exactly as Session.Compact produces it. + message.Message{Role: message.RoleUser, Parts: message.Parts{&message.Text{Text: summaryText}}}, + // The first kept turn's user prompt, immediately following — the + // shape ordinary operation never produces. + message.Message{Role: message.RoleUser, Parts: message.Parts{&message.Text{Text: "keep going"}}}, + message.Message{Role: message.RoleAssistant, Parts: message.Parts{&message.Text{Text: "ok"}}}, + )) + + // Exactly two wire messages: the merged user turn, then the assistant + // reply — never three (which would violate strict alternation). + if len(out.Messages) != 2 { + t.Fatalf("wire messages = %d, want 2 (merged user turn + assistant reply)", len(out.Messages)) + } + first := out.Messages[0] + if first.Role != "user" { + t.Fatalf("first wire message role = %q, want user", first.Role) + } + if len(first.Content) != 2 { + t.Fatalf("merged user message content blocks = %d, want 2 (summary text + kept prompt text)", len(first.Content)) + } + if first.Content[0].Text != summaryText { + t.Errorf("first content block = %q, want the summary text", first.Content[0].Text) + } + if first.Content[1].Text != "keep going" { + t.Errorf("second content block = %q, want the kept turn's prompt", first.Content[1].Text) + } + if out.Messages[1].Role != "assistant" { + t.Errorf("second wire message role = %q, want assistant", out.Messages[1].Role) + } +} + func containsAll(ss []string, wants ...string) bool { for _, w := range wants { found := false diff --git a/provider/openai/openai.go b/provider/openai/openai.go index 83377b0..a15412f 100644 --- a/provider/openai/openai.go +++ b/provider/openai/openai.go @@ -286,9 +286,18 @@ func (s *stream) handle(name string, data []byte) error { if err := json.Unmarshal(data, &ev); err != nil { return fmt.Errorf("openai: bad %s: %w", name, err) } - s.usage.InputTokens = ev.Response.Usage.InputTokens + // The Responses API reports input_tokens INCLUSIVE of the cached + // portion (input_tokens_details.cached_tokens is a subset). The + // provider.Usage contract wants disjoint components, so report the + // uncached remainder; the sum reconstructs the wire total. + cached := ev.Response.Usage.InputTokensDetails.CachedTokens + uncached := ev.Response.Usage.InputTokens - cached + if uncached < 0 { + uncached = 0 + } + s.usage.InputTokens = uncached s.usage.OutputTokens = ev.Response.Usage.OutputTokens - s.usage.CacheReadTokens = ev.Response.Usage.InputTokensDetails.CachedTokens + s.usage.CacheReadTokens = cached var stop provider.StopReason switch { diff --git a/provider/openai/stream_test.go b/provider/openai/stream_test.go index 7d48b35..bb9a57f 100644 --- a/provider/openai/stream_test.go +++ b/provider/openai/stream_test.go @@ -118,7 +118,10 @@ func TestStreamAssembly(t *testing.T) { if done.StopReason != provider.StopToolUse { t.Errorf("stop reason = %s", done.StopReason) } - if done.Usage.InputTokens != 100 || done.Usage.OutputTokens != 42 || done.Usage.CacheReadTokens != 25 { + // Disjoint contract (see provider.Usage): input_tokens from the wire is + // cache-INCLUSIVE (100 total, 25 cached), so the adapter must report the + // uncached remainder: 75 + 25 sums back to the true prompt size. + if done.Usage.InputTokens != 75 || done.Usage.OutputTokens != 42 || done.Usage.CacheReadTokens != 25 { t.Errorf("usage = %+v", done.Usage) } @@ -213,7 +216,7 @@ func TestStreamIncomplete(t *testing.T) { if done.StopReason != tc.want { t.Errorf("stop reason = %s, want %s", done.StopReason, tc.want) } - if done.Usage.InputTokens != 7 || done.Usage.OutputTokens != 9 || done.Usage.CacheReadTokens != 2 { + if done.Usage.InputTokens != 5 || done.Usage.OutputTokens != 9 || done.Usage.CacheReadTokens != 2 { // disjoint: 7 wire total - 2 cached t.Errorf("usage = %+v", done.Usage) } msg := done.Message diff --git a/provider/provider.go b/provider/provider.go index 488d425..b389596 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -48,6 +48,15 @@ const ( ) // Usage is token accounting for one request. +// Usage reports token accounting for one provider call. +// +// CONTRACT: the three input components are DISJOINT — InputTokens is the +// uncached portion only, and the true prompt size is InputTokens + +// CacheReadTokens + CacheWriteTokens. Adapters whose upstream reports a +// cache-inclusive total (OpenAI Responses: input_tokens includes +// input_tokens_details.cached_tokens) must subtract before populating. +// Consumers (auto-compaction thresholds, cost accounting) rely on the sum +// being the prompt size on every provider. type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` diff --git a/server/compact_test.go b/server/compact_test.go new file mode 100644 index 0000000..9c9ab4a --- /dev/null +++ b/server/compact_test.go @@ -0,0 +1,268 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" +) + +// compactAsstTurn builds a scripted assistant reply carrying usage, with a +// fresh unique message ID per call (server package's shared asstTurn helper +// hardcodes a deterministic ID, which is fine for ordinary tests but +// collides across turns for compaction's ID-based splice/range assertions). +var compactTurnSeq int + +func compactAsstTurn(text string, usage provider.Usage) []provider.Event { + compactTurnSeq++ + msg := &message.Message{ + ID: fmt.Sprintf("msg_asst_%d", compactTurnSeq), + Role: message.RoleAssistant, + Parts: message.Parts{&message.Text{Text: text}}, + } + return []provider.Event{{Type: provider.EventDone, Message: msg, StopReason: provider.StopEndTurn, Usage: usage}} +} + +// promptAndWaitIdle posts a synchronous-from-the-test's-point-of-view +// prompt_async (waits on GET /session/{id}/wait?until=idle before +// returning), so a test can build up turn history without manually +// polling SSE. +func (h *harness) promptAndWaitIdle(id, text string) { + h.t.Helper() + resp, data := h.do("POST", "/session/"+id+"/prompt_async", map[string]any{ + "parts": []map[string]string{{"type": "text", "text": text}}, + }) + if resp.StatusCode != http.StatusAccepted { + h.t.Fatalf("prompt_async status %d: %s", resp.StatusCode, data) + } + resp, data = h.do("GET", "/session/"+id+"/wait?until=idle&timeout_s=5", nil) + if resp.StatusCode != http.StatusOK { + h.t.Fatalf("wait status %d: %s", resp.StatusCode, data) + } +} + +func (h *harness) getSessionJSON(id string) sessionJSON { + h.t.Helper() + resp, data := h.do("GET", "/session/"+id, nil) + if resp.StatusCode != http.StatusOK { + h.t.Fatalf("get session status %d: %s", resp.StatusCode, data) + } + var sess sessionJSON + if err := json.Unmarshal(data, &sess); err != nil { + h.t.Fatalf("decode session: %v (%s)", err, data) + } + return sess +} + +// TestCompactEndpointFoldsHistoryAndReportsResult is the red-first test for +// POST /session/{id}/compact's happy path: it folds the oldest turns, +// returns turns_folded/first_id/last_id/summary, and GET /session then +// shows compaction happened. +func TestCompactEndpointFoldsHistoryAndReportsResult(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactAsstTurn("one", provider.Usage{InputTokens: 10}), + compactAsstTurn("two", provider.Usage{InputTokens: 20}), + compactAsstTurn("three", provider.Usage{InputTokens: 30}), + compactAsstTurn("SUMMARY", provider.Usage{InputTokens: 5}), + }} + h := newHarness(t, prov) + id := h.createSession("test/m1") + + h.promptAndWaitIdle(id, "go1") + h.promptAndWaitIdle(id, "go2") + h.promptAndWaitIdle(id, "go3") + + before := h.getSessionJSON(id) + if before.CompactionCount != 0 { + t.Fatalf("CompactionCount before compact = %d, want 0", before.CompactionCount) + } + + resp, data := h.do("POST", "/session/"+id+"/compact", map[string]any{"keep_turns": 1}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("compact status %d: %s", resp.StatusCode, data) + } + var out compactResponseJSON + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("decode compact response: %v (%s)", err, data) + } + if out.TurnsFolded != 2 { + t.Fatalf("turns_folded = %d, want 2", out.TurnsFolded) + } + if out.FirstID == "" || out.LastID == "" { + t.Errorf("first_id/last_id empty: %+v", out) + } + if out.Summary == nil || out.Summary.Parts.Text() == "" { + t.Fatalf("summary missing or empty: %+v", out) + } + + after := h.getSessionJSON(id) + if after.CompactionCount != 1 { + t.Errorf("CompactionCount after compact = %d, want 1", after.CompactionCount) + } + if after.LastCompactedAt.IsZero() { + t.Error("LastCompactedAt is zero after a successful compaction") + } + + // The messages endpoint reflects the trimmed history: the summary + // message, then the kept turn's user+assistant pair. + _, data = h.do("GET", "/session/"+id+"/message", nil) + var msgs []message.Message + if err := json.Unmarshal(data, &msgs); err != nil { + t.Fatal(err) + } + if len(msgs) != 3 { + t.Fatalf("messages after compact = %d, want 3", len(msgs)) + } + if msgs[0].ID != out.Summary.ID { + t.Errorf("messages[0].ID = %q, want the summary id %q", msgs[0].ID, out.Summary.ID) + } +} + +// TestCompactEndpointKeepTurnsFloor is the red-first test for the hard +// floor on keep_turns: 0 or negative is a 400, never silently clamped. +func TestCompactEndpointKeepTurnsFloor(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactAsstTurn("one", provider.Usage{InputTokens: 10}), + }} + h := newHarness(t, prov) + id := h.createSession("test/m1") + h.promptAndWaitIdle(id, "go1") + + for _, kt := range []int{0, -1, -5} { + resp, data := h.do("POST", "/session/"+id+"/compact", map[string]any{"keep_turns": kt}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("keep_turns=%d status = %d, want 400: %s", kt, resp.StatusCode, data) + } + } +} + +// TestCompactEndpointNoopReturns200WithZeroTurnsFolded is the red-first test +// for §2's minimum-fold rule at the wire boundary: nothing worth folding is +// a 200 with turns_folded 0, never an error. +func TestCompactEndpointNoopReturns200WithZeroTurnsFolded(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactAsstTurn("one", provider.Usage{InputTokens: 10}), + }} + h := newHarness(t, prov) + id := h.createSession("test/m1") + h.promptAndWaitIdle(id, "go1") + + resp, data := h.do("POST", "/session/"+id+"/compact", map[string]any{}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("compact status %d: %s", resp.StatusCode, data) + } + var out compactResponseJSON + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + if out.TurnsFolded != 0 { + t.Errorf("turns_folded = %d, want 0 (only 1 turn exists, default keep_turns is 2)", out.TurnsFolded) + } +} + +// TestCompactEndpointBusySessionIs409 is the red-first test for the run-slot +// discipline (docs/design/context-compaction.md §4): a compaction request +// against an already-busy session is rejected with 409, exactly like +// prompt_async/goal. +func TestCompactEndpointBusySessionIs409(t *testing.T) { + prov := newBlockingProvider("test") + h := newHarness(t, prov) + id := h.createSession("test/m1") + + resp, data := h.do("POST", "/session/"+id+"/prompt_async", map[string]any{ + "parts": []map[string]string{{"type": "text", "text": "hang"}}, + }) + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("prompt_async status %d: %s", resp.StatusCode, data) + } + <-prov.started + + resp, data = h.do("POST", "/session/"+id+"/compact", map[string]any{}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("compact on busy session status = %d, want 409: %s", resp.StatusCode, data) + } + + prov.releaseAll() + h.do("GET", "/session/"+id+"/wait?until=idle&timeout_s=5", nil) +} + +// TestCompactEndpointUnknownSessionIs404 mirrors prompt_async/goal's +// unknown-session handling. +func TestCompactEndpointUnknownSessionIs404(t *testing.T) { + h := newHarness(t, &scriptedProvider{name: "test"}) + resp, data := h.do("POST", "/session/ses_nope/compact", map[string]any{}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404: %s", resp.StatusCode, data) + } +} + +// TestCompactEndpointRequiresAuth mirrors every other write endpoint's +// run-token auth requirement. +func TestCompactEndpointRequiresAuth(t *testing.T) { + h := newHarness(t, &scriptedProvider{name: "test"}) + id := h.createSession("test/m1") + + req, err := http.NewRequest("POST", h.ts.URL+"/session/"+id+"/compact", nil) + if err != nil { + t.Fatal(err) + } + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status without auth = %d, want 401", resp.StatusCode) + } +} + +// TestCompactEndpointSummaryEventBeforeHistoryCompactedEvent is the +// red-first test for §4's live event surface at the server boundary: an SSE +// tailer sees the summary's "message" event strictly before the durable +// "history.compacted" event. +func TestCompactEndpointSummaryEventBeforeHistoryCompactedEvent(t *testing.T) { + prov := &scriptedProvider{name: "test", turns: [][]provider.Event{ + compactAsstTurn("one", provider.Usage{InputTokens: 10}), + compactAsstTurn("two", provider.Usage{InputTokens: 10}), + compactAsstTurn("gist", provider.Usage{InputTokens: 5}), + }} + h := newHarness(t, prov) + id := h.createSession("test/m1") + h.promptAndWaitIdle(id, "go1") + h.promptAndWaitIdle(id, "go2") + + sse := h.openSSE("?from=0", "") + resp, data := h.do("POST", "/session/"+id+"/compact", map[string]any{"keep_turns": 1}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("compact status %d: %s", resp.StatusCode, data) + } + var out compactResponseJSON + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + + var sawSummaryMessage, sawCompacted bool + for !sawCompacted { + ev := sse.nextEvent(t) + switch ev.Type { + case "message": + if ev.Message != nil && ev.Message.ID == out.Summary.ID { + sawSummaryMessage = true + } + case "history.compacted": + if !sawSummaryMessage { + t.Fatal("history.compacted event arrived before the summary's message event") + } + sawCompacted = true + if ev.CompactTurnsFolded != out.TurnsFolded || ev.CompactSummaryID != out.Summary.ID { + t.Errorf("history.compacted event = %+v, want it to carry the compact result", ev) + } + } + } + if !sawSummaryMessage { + t.Fatal("never saw the summary's message event") + } +} diff --git a/server/handlers.go b/server/handlers.go index 3d20914..71374c0 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -16,6 +16,7 @@ import ( "github.com/majorcontext/harness/engine" "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/plugin" ) // sessionJSON is the openapi Session shape. @@ -70,6 +71,15 @@ type sessionJSON struct { // Absent (omitempty) when the session has no recorded parent — the // common case. ParentSession string `json:"parent_session,omitempty"` + // CompactionCount/LastCompactedAt surface whether and when this session + // has been compacted (docs/design/context-compaction.md), auto- + // triggered or via POST /session/{id}/compact — so a UI can show that + // compaction happened. CompactionCount is 0 (omitted) until the first + // compaction; LastCompactedAt is the zero Time (omitted) likewise. Both + // survive a restart (engine.Session.CompactionCount/LastCompactedAt + // replay the compact journal record — see engine/store.go). + CompactionCount int `json:"compaction_count,omitempty"` + LastCompactedAt time.Time `json:"last_compacted_at,omitzero"` } // usageJSON is the Session/StatusEntry usage sub-object (issue #62 layer 2): @@ -1069,6 +1079,104 @@ func (s *Server) handleAbort(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// compactResponseJSON is the openapi POST /session/{id}/compact response +// shape (docs/design/context-compaction.md §1): turns_folded is 0 (not an +// error) when there was nothing worth folding — see engine.CompactResult. +type compactResponseJSON struct { + TurnsFolded int `json:"turns_folded"` + FirstID string `json:"first_id,omitempty"` + LastID string `json:"last_id,omitempty"` + Summary *message.Message `json:"summary,omitempty"` +} + +// handleCompact is POST /session/{id}/compact (docs/design/context- +// compaction.md §1 "Explicit: POST /session/{id}/compact"): always +// available regardless of the automatic threshold. It claims the session's +// single run slot exactly like prompt_async/goal (409 if already running, +// 503 if draining) — compaction never runs concurrently with a turn — then +// runs synchronously (the response carries the full result, so there is no +// async job to poll). Optional JSON body {"keep_turns": N, "model": "..."} +// overrides Config.CompactionKeepTurns/CompactionModel for this call only; +// keep_turns has a hard floor of 1 — 0 or negative is a 400, never silently +// clamped, so a caller's mistake is visible rather than silently ignored. +func (s *Server) handleCompact(w http.ResponseWriter, r *http.Request) { + id, ok := s.sessionIDOrNotFound(w, r) + if !ok { + return + } + var body struct { + KeepTurns *int `json:"keep_turns"` + Model string `json:"model"` + } + if err := decodeBody(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if body.KeepTurns != nil && *body.KeepTurns <= 0 { + writeErr(w, http.StatusBadRequest, "keep_turns must be >= 1") + return + } + var model message.ModelRef + if body.Model != "" { + m, err := message.ParseModelRef(body.Model) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + model = m + } + + st, ctx, _, code, holder := s.claimForPrompt(id) + if code != 0 { + switch { + case code == http.StatusConflict && holder != "": + writeErr(w, code, fmt.Sprintf("workdir busy: held by session %s", holder)) + case code == http.StatusConflict: + writeErr(w, code, "session is busy with another prompt") + case code == http.StatusServiceUnavailable: + writeErr(w, code, "server shutting down") + default: + writeErr(w, http.StatusNotFound, "no such session") + } + return + } + s.emitDurable(Event{Type: evtSessionStatus, SessionID: id, Status: "busy"}) + + opts := engine.CompactOptions{Model: model} + if body.KeepTurns != nil { + opts.KeepTurns = *body.KeepTurns + } + res, err := st.sess.Compact(ctx, opts) + // Session.Compact's own emits (EventMessage for the summary, then + // EventHistoryCompacted — see engine/compact.go) already flowed through + // Publish synchronously by the time Compact returns, journaling the + // summary message and the durable history.compacted record in that + // order (see publishHistoryCompacted). syncMessages here is a harmless, + // idempotent extra pass — the same belt-and-suspenders every other + // handler's tail already relies on. + s.syncMessages(id) + + s.mu.Lock() + st.running = false + st.cancel = nil + st.lastUsed = time.Now() + s.evictResidentLocked() + s.mu.Unlock() + s.wg.Done() + s.emitDurable(Event{Type: evtSessionStatus, SessionID: id, Status: "idle"}) + + if err != nil { + writeErr(w, http.StatusInternalServerError, plugin.SanitizeSessionError(err.Error())) + return + } + writeJSON(w, http.StatusOK, compactResponseJSON{ + TurnsFolded: res.TurnsFolded, + FirstID: res.FirstID, + LastID: res.LastID, + Summary: res.Summary, + }) +} + // sessionOnDisk reports whether a session log for id exists in the session // directory, without loading the session. func (s *Server) sessionOnDisk(id string) bool { @@ -1271,19 +1379,21 @@ func (s *Server) buildSession(sess *engine.Session, status string) sessionJSON { lastTurn := s.lastTurnJSONLocked(id) s.mu.Unlock() return sessionJSON{ - ID: id, - CreatedAt: sess.CreatedAt(), - Model: sess.Model(), - Status: status, - State: compositeState(status == "busy", goal != nil && goal.Active, isRestartPaused(goal)), - Messages: len(sess.History()), - Seq: seq, - Goal: goal, - WorkDir: sess.WorkDir(), - LastTurn: lastTurn, - Usage: usageJSONForSession(sess), - LastActivityAt: sess.LastActivityAt(), - ParentSession: sess.ParentSession(), + ID: id, + CreatedAt: sess.CreatedAt(), + Model: sess.Model(), + Status: status, + State: compositeState(status == "busy", goal != nil && goal.Active, isRestartPaused(goal)), + Messages: len(sess.History()), + Seq: seq, + Goal: goal, + WorkDir: sess.WorkDir(), + LastTurn: lastTurn, + Usage: usageJSONForSession(sess), + LastActivityAt: sess.LastActivityAt(), + ParentSession: sess.ParentSession(), + CompactionCount: sess.CompactionCount(), + LastCompactedAt: sess.LastCompactedAt(), } } diff --git a/server/journal.go b/server/journal.go index 8ea3de1..dd85613 100644 --- a/server/journal.go +++ b/server/journal.go @@ -89,6 +89,18 @@ type Event struct { // the path a 'worktree'-isolation session's tools ran in. Present only on // those two event types. WorktreePath string `json:"worktree_path,omitempty"` + + // Compaction fields (see docs/design/context-compaction.md §4 "Live + // event surface"). Carried on the durable evtHistoryCompacted record: + // CompactFirstID/CompactLastID name the folded range's boundary + // messages, CompactTurnsFolded is the fold count, and + // CompactSummaryID names the summary message — already delivered via a + // preceding evtMessage record (see Publish/publishHistoryCompacted). + // evtCompactionFailed (live only, never journaled) carries only Error. + CompactFirstID string `json:"compact_first_id,omitempty"` + CompactLastID string `json:"compact_last_id,omitempty"` + CompactTurnsFolded int `json:"compact_turns_folded,omitempty"` + CompactSummaryID string `json:"compact_summary_id,omitempty"` } // Durable and live event types (a superset of engine.Event types plus the @@ -123,6 +135,18 @@ const ( // on the ordinary clean-teardown path, purely for observability. evtWorktreeKept = "workdir.worktree_kept" evtWorktreeRemoved = "workdir.worktree_removed" + + // evtHistoryCompacted is the durable record of a successful compaction + // (see engine/compact.go's Session.Compact and docs/design/context- + // compaction.md §4): journaled after the summary message itself has + // already flowed through an evtMessage record, via Publish's routing of + // engine.EventHistoryCompacted — the "reconciliation signal" telling a + // replaying tailer which prefix the just-seen summary message replaced. + evtHistoryCompacted = "history.compacted" + // evtCompactionFailed is compaction's fire-and-forget failure + // counterpart — live only, never journaled (a failed compaction never + // mutates durable state, so there is nothing to reconcile on replay). + evtCompactionFailed = "compaction.failed" ) const journalName = "events.jsonl" @@ -183,9 +207,31 @@ func (s *Server) Publish(ev engine.Event) { }) case engine.EventGoalSet, engine.EventGoalEval, engine.EventGoalStalled, engine.EventGoalAchieved, engine.EventGoalCleared: s.publishGoal(ev) + case engine.EventHistoryCompacted: + s.publishHistoryCompacted(ev) + case engine.EventCompactionFailed: + s.publishLive(Event{Type: evtCompactionFailed, SessionID: ev.SessionID, Error: ev.Text}) } } +// publishHistoryCompacted journals the durable history.compacted record for +// a successful compaction (see engine/compact.go's Session.Compact). It is +// called synchronously from within Session.Compact's own emit sequence — +// AFTER the summary message's EventMessage has already been published (see +// Publish's engine.EventMessage case, which journals via syncMessages) — so +// the journal order is always summary message, then history.compacted, +// exactly as docs/design/context-compaction.md §4 requires. +func (s *Server) publishHistoryCompacted(ev engine.Event) { + s.emitDurable(Event{ + Type: evtHistoryCompacted, + SessionID: ev.SessionID, + CompactFirstID: ev.CompactFirstID, + CompactLastID: ev.CompactLastID, + CompactTurnsFolded: ev.CompactTurnsFolded, + CompactSummaryID: ev.CompactSummaryID, + }) +} + // publishGoal journals a durable goal.* record and folds the event into the // per-session goal tracker that backs the Session JSON goal field. // diff --git a/server/openapi.yaml b/server/openapi.yaml index 26b2ba3..eb95202 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -162,6 +162,20 @@ components: or validates it beyond a length cap. Absent (omitempty) when the session has no recorded parent, the common case. example: ses_66aa9a7c6f323a7a + compaction_count: + type: integer + description: > + How many times this session has been compacted (auto-triggered + or via POST /session/{id}/compact) — see docs/design/context- + compaction.md. Absent (0) until the first compaction. Survives a + restart: the engine replays the compact journal record on load. + last_compacted_at: + type: string + format: date-time + description: > + When the most recent compaction landed. Absent until the first + compaction. Together with compaction_count this is what a UI + uses to show that compaction happened. Usage: type: object @@ -573,6 +587,8 @@ components: - goal.paused - workdir.worktree_kept - workdir.worktree_removed + - history.compacted + - compaction.failed - text.delta - reasoning.delta - tool.start @@ -649,6 +665,17 @@ components: unpushed commits and was left in place — the durable signal an orchestrator relies on to find and finish that work rather than lose it; worktree_removed means it had neither and was deleted. + history.compacted is the durable record of a successful + compaction (docs/design/context-compaction.md), auto-triggered + or via POST /session/{id}/compact: it carries + compact_first_id/compact_last_id (the folded range's boundary + message ids), compact_turns_folded, and compact_summary_id — the + summary message itself always arrives first, as an ordinary + `message` event, so this is the reconciliation signal telling a + replaying tailer which prefix that summary replaced. + compaction.failed is compaction's fire-and-forget failure + counterpart (never journaled — a failed compaction never mutates + durable state), carrying the failure detail in `error`. session_id: { type: string } seq: type: integer @@ -759,6 +786,61 @@ components: description: > The worktree directory (workdir.worktree_kept / workdir.worktree_removed). + compact_first_id: + type: string + description: The folded range's first message id (history.compacted). + compact_last_id: + type: string + description: The folded range's last message id (history.compacted). + compact_turns_folded: + type: integer + description: Number of whole turns folded (history.compacted). + compact_summary_id: + type: string + description: > + The id of the summary message that replaced the folded range — + already delivered via a preceding `message` event + (history.compacted). + + CompactRequest: + type: object + description: > + Optional body for POST /session/{id}/compact. An absent/empty body + uses Config.CompactionKeepTurns/CompactionModel. + properties: + keep_turns: + type: integer + description: > + Overrides Config.CompactionKeepTurns for this call only. Hard + floor of 1 — the most recent turn is never foldable. 0 or + negative is a 400, never silently clamped. + model: + $ref: "#/components/schemas/ModelRef" + description: > + Overrides the model used for the summarization call. Defaults + to the session's own current model. + + CompactResponse: + type: object + required: [turns_folded] + properties: + turns_folded: + type: integer + description: > + Number of whole turns folded. 0 (not an error) when there was + nothing worth folding — fewer than the effective keep_turns' + worth of complete turns exist yet. + first_id: + type: string + description: The folded range's first message id. Absent when turns_folded is 0. + last_id: + type: string + description: The folded range's last message id. Absent when turns_folded is 0. + summary: + $ref: "#/components/schemas/Message" + description: > + The synthesized summary message that replaced the folded range. + Absent when turns_folded is 0. GoalRequest: type: object @@ -1135,6 +1217,60 @@ paths: application/json: schema: { $ref: "#/components/schemas/Error" } + /session/{id}/compact: + post: + operationId: compactSession + summary: Fold the oldest turns of this session's history into one summary. + description: > + docs/design/context-compaction.md's explicit escape hatch: always + available regardless of the automatic threshold (Config. + ContextWindowTokens/CompactionThreshold) — pre-emptive compaction + ahead of a known-large tool result, operator-triggered cleanup, or a + caller that disables the automatic path entirely and drives it + manually. Claims the session's single run slot exactly like + prompt_async/goal (so a concurrent prompt_async or goal is 409), but + runs synchronously — the response carries the full result, there is + no async job to poll. A successful, non-empty compaction emits the + summary via an ordinary `message` event first, then a durable + `history.compacted` event/record (see the Event schema). + parameters: + - $ref: "#/components/parameters/sessionID" + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/CompactRequest" } + responses: + "200": + description: > + OK. turns_folded is 0 (not an error) when there was nothing + worth folding yet. + content: + application/json: + schema: { $ref: "#/components/schemas/CompactResponse" } + "400": + description: keep_turns is present and <= 0, or model is malformed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: > + Session is busy with another prompt or goal, OR another + currently-running session holds the same workdir and neither + session set share_workdir on creation, and neither session set + workdir_isolation="worktree" (the error names the holder + session). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: The summarization call itself failed; compaction aborted cleanly (no mutation, no journal write). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /session/{id}/goal: post: operationId: startGoal diff --git a/server/server.go b/server/server.go index 82e77c4..da4eb81 100644 --- a/server/server.go +++ b/server/server.go @@ -413,6 +413,7 @@ func (s *Server) routes() { mux.HandleFunc("GET /session/{id}/message", s.auth(s.handleMessages)) mux.HandleFunc("GET /session/{id}/request", s.auth(s.handleRequest)) mux.HandleFunc("POST /session/{id}/prompt_async", s.auth(s.handlePrompt)) + mux.HandleFunc("POST /session/{id}/compact", s.auth(s.handleCompact)) mux.HandleFunc("POST /session/{id}/goal", s.auth(s.handleGoal)) mux.HandleFunc("DELETE /session/{id}/goal", s.auth(s.handleGoalDelete)) mux.HandleFunc("POST /session/{id}/abort", s.auth(s.handleAbort))