package agent import ( "context" "fmt" "strconv" "strings" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/metrics" ) // metricsCompaction records a compaction so its effect on later turns is // visible in the event log. func metricsCompaction(inputTokens, before, after int) metrics.Event { return metrics.Event{ Type: "compaction", InputTokens: inputTokens, Extra: map[string]any{"messages_before": before, "messages_after": after}, } } // DefaultContextWindow is the Claude Opus 5 window. Lower it with // Options.ContextWindow when running a model with less room. const DefaultContextWindow = 1_000_000 // DefaultCompactAt is the fraction of the window at which to summarize. // Compaction itself needs headroom — it is a model call with the history in it // — so waiting until the window is nearly full means the compaction request is // the one that fails. It is also the single biggest thing that breaks prompt // caching, which is the other argument for doing it early and rarely. const DefaultCompactAt = 0.7 // DefaultCompactFloor is the fraction at which compaction stops being optional. // The normal threshold refuses to cut a history it cannot split safely — a // handful of enormous tool results is too few messages for keepRecentMessages — // and below the floor that restraint is right. Once the window is this full it // is not: a tail kept verbatim is worth nothing if the next request has no room // to be sent. The floor folds with keepUrgentMessages instead. const DefaultCompactFloor = 0.97 // keepRecentMessages is how much of the tail survives compaction verbatim. The // model is usually mid-thought, and a summary of the last two minutes of work // is worse than the work itself. const keepRecentMessages = 6 // keepUrgentMessages is the tail the floor settles for. For any conversation of // real length this changes nothing — the split lands on the midpoint either way // — so its only job is to let a short history of enormous messages be cut at // all. One is the smallest tail that is still a valid conversation: the folded // summary takes the user slot, and one assistant message follows it. const keepUrgentMessages = 1 const compactPrompt = `Summarize the conversation above for a coding agent that will keep working on the same task with this summary in place of the transcript. Preserve, in this order: 1. The original task, verbatim enough that nothing about it is lost. 2. Every file touched so far and what was changed in it, with paths. 3. Decisions made and the reasoning that is not re-derivable from the code. 4. Things discovered the hard way: where something actually lives, what a test really asserts, what did not work and why. 5. What is left to do. Be specific. Names, paths and line numbers are worth more than prose. Omit pleasantries and anything already visible in the current state of the files.` // thresholds resolves the two fractions against their defaults. The floor is // never allowed below CompactAt: a -compact-at set higher than the floor would // otherwise make every ordinary compaction take the aggressive keep and throw // away the recent tail the model is still working from. func (a *Agent) thresholds() (window int, at, floor float64) { window = a.opts.ContextWindow if window <= 0 { window = DefaultContextWindow } at = a.opts.CompactAt if at <= 0 { at = DefaultCompactAt } floor = a.opts.CompactFloor if floor <= 0 { floor = DefaultCompactFloor } if floor < at { floor = at } return window, at, floor } // ContextFullError ends a turn that cannot be sent: the window is full and // auto-compaction is off, so nothing is going to make room on its own. It is a // typed error because a front end should offer the ways out rather than print // a failure and leave the user to guess at them. type ContextFullError struct { Used, Window int Floor float64 } func (e *ContextFullError) Error() string { return fmt.Sprintf("the context window is %d%% full (%s of %s tokens) and auto-compaction is off", e.Used*100/e.Window, thousands(e.Used), thousands(e.Window)) } // thousands groups an integer for reading: 970000 becomes 970,000. func thousands(n int) string { s := strconv.Itoa(n) for i := len(s) - 3; i > 0; i -= 3 { s = s[:i] + "," + s[i:] } return s } // contextFull is the hard stop that replaces compaction when auto-compaction is // off. Below the floor it is nil and the turn proceeds as normal. It takes the // token count rather than reading a.lastInputTokens so that it and maybeCompact // cannot end up judging different numbers. func (a *Agent) contextFull(lastInputTokens int) error { if !a.opts.NoAutoCompact { return nil } window, _, floor := a.thresholds() if float64(lastInputTokens) < floor*float64(window) { return nil } return &ContextFullError{Used: lastInputTokens, Window: window, Floor: floor} } // maybeCompact summarizes the older half of the conversation when the last // request came close to filling the window. It reports whether it compacted. func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) { // Off, the window is a wall rather than a trigger: stop while the session // is still intact and let the user decide between /compact and /clear. if a.opts.NoAutoCompact { return false, a.contextFull(lastInputTokens) } window, at, floor := a.thresholds() used := float64(lastInputTokens) urgent := used >= floor*float64(window) if !urgent && used < at*float64(window) { return false, nil } did, err := a.compact(ctx, lastInputTokens, keepRecentMessages) if err != nil || did || !urgent { return did, err } // Past the floor and the normal keep declined: fold with the smaller tail // rather than let the next request run into the wall. did, err = a.compact(ctx, lastInputTokens, keepUrgentMessages) if err != nil || did { return did, err } a.emit(Event{Kind: EventNotice, Text: fmt.Sprintf( "the context window is %d%% full and there is nothing left that can be folded away — "+ "the next request may not fit. /clear starts over, or narrow what the agent is holding open.", int(floor*100))}) return false, nil } // Compact folds the conversation on demand, which is what /compact asks for. It // keeps the same tail an automatic compaction keeps, and reports false rather // than spending a model call when there is nothing worth folding. // // Past the floor it settles for the smaller tail, for the same reason the floor // does — and because with auto-compaction off this is the only thing between a // full window and /clear. Declining there would be Codey refusing the one // move the user has left. func (a *Agent) Compact(ctx context.Context) (bool, error) { did, err := a.compact(ctx, a.lastInputTokens, keepRecentMessages) if err != nil || did { return did, err } window, _, floor := a.thresholds() if float64(a.lastInputTokens) < floor*float64(window) { return false, nil } return a.compact(ctx, a.lastInputTokens, keepUrgentMessages) } // compact folds everything before the split into one summary message, keeping // the last keep messages verbatim. It reports whether it found anything to fold. func (a *Agent) compact(ctx context.Context, lastInputTokens, keep int) (bool, error) { history := a.state.History() split := compactionSplit(history, keep) if split < 0 { return false, nil // nothing safe to fold away yet } summary, err := a.summarize(ctx, history[:split]) if err != nil { return false, err } // The task itself is the one thing never summarized: everything downstream // is judged against it. task := originalTask(history) folded := llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{ llm.TextBlock(fmt.Sprintf("[Earlier conversation, compacted]\n\nOriginal task:\n%s\n\nWhat has happened since:\n%s", task, summary)), }} a.state.Replace(append([]llm.Message{folded}, history[split:]...)) a.log.Log(metricsCompaction(lastInputTokens, len(history), a.state.Len())) // The recorded input size described the history that has just been thrown // away, so it is no longer a fact about anything. Zero means "unknown", // which is the truth until the next request measures the window again — // and leaving the old number would keep the full-window stop firing on a // conversation that is no longer full, with no way out but /clear. a.lastInputTokens = 0 a.emit(Event{Kind: EventCompact, Text: summary}) return true, nil } // compactionSplit picks where to cut, leaving at least keep messages in the // tail. The kept tail must begin with an assistant message: the folded summary // takes the user slot before it, and a tail starting with a tool_result user // message would reference a tool_use that no longer exists, which the API // rejects. func compactionSplit(history []llm.Message, keep int) int { if len(history) <= keep+2 { return -1 } want := len(history) / 2 if max := len(history) - keep; want > max { want = max } for i := want; i < len(history); i++ { if history[i].Role == llm.RoleAssistant { return i } } return -1 } // summarize asks the model to fold the given messages into prose. It runs // without tools: this call must not be able to touch the repository. func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, error) { msgs := append([]llm.Message(nil), history...) msgs = append(msgs, llm.Message{ Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(compactPrompt)}, }) req := llm.Request{ MaxTokens: 4096, Messages: stripTrailingToolUse(msgs), Thinking: a.thinking(), } // This is the one request in the agent that is built by hand rather than // through (*Agent).request/systemBlocks, and it is deliberately bare — no // tools, no system prompt of ours — so it is easy to forget that an OAuth // credential still requires the Claude Code identity string as the FIRST // system block. Skip it here and the API does not 401; it returns a 429 // that (*APIError).Retryable treats as transient, so the request burns // three attempts with backoff before the whole turn aborts. Do not "clean // up" this lone system block just because there is no other system prompt // alongside it — for an OAuth session, this is the whole system prompt. if a.opts.SystemPrefix != "" { req.System = []llm.SystemBlock{{Type: "text", Text: a.opts.SystemPrefix}} } resp, err := a.client.CreateMessage(ctx, req) if err != nil { return "", fmt.Errorf("compaction: %w", err) } text := strings.TrimSpace(resp.Text()) if text == "" { return "", fmt.Errorf("compaction produced no summary") } return text, nil } // stripTrailingToolUse drops a dangling assistant tool_use at the end, which // would otherwise demand tool_result blocks the summarization request has no // way to supply. func stripTrailingToolUse(msgs []llm.Message) []llm.Message { for len(msgs) > 0 { last := msgs[len(msgs)-1] if last.Role != llm.RoleAssistant { break } var hasToolUse bool for _, b := range last.Content { if b.Type == llm.BlockToolUse { hasToolUse = true break } } if !hasToolUse { break } msgs = msgs[:len(msgs)-1] } return msgs } // originalTask returns the first user message's text. func originalTask(history []llm.Message) string { for _, m := range history { if m.Role != llm.RoleUser { continue } var b strings.Builder for _, blk := range m.Content { if blk.Type == llm.BlockText { b.WriteString(blk.Text) } } if s := strings.TrimSpace(b.String()); s != "" { return s } } return "(unrecorded)" }