mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* perf(tui): disable duplicate composer cursor blink * perf(tui): stop composer wakeups after idle * refactor(tools): publish immutable registry snapshots * perf(startup): defer optional tool discovery * perf(provider): reuse compatible response sessions * feat(provider): add native patch tool input * fix(provider): recognize saved chat sessions * perf(tui): render only on screen changes * fix(runtime): address performance review findings * fix(usage): expose cache efficiency details * perf(agent): reduce redundant bounded-task turns * fix: harden runtime review edge cases * fix: address runtime review findings * build: restore official Bubble Tea dependency * fix(cli): redact MCP registration errors * fix: harden turn session regressions * fix: keep run tools and patch roots consistent * fix: bind freeform patch writes to scoped roots * test: keep patch fixtures out of worktree * fix(tools): reject mixed-root freeform patches
853 lines
36 KiB
Go
853 lines
36 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Gitlawb/zero/internal/trace"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// Session compaction.
|
|
//
|
|
// When a running conversation approaches the model's context window, the oldest
|
|
// middle of the history is summarized into a single user-role note while the
|
|
// system prompt(s) and the most recent turns are kept verbatim. This keeps long
|
|
// sessions from blowing the context budget.
|
|
//
|
|
// Compact is a PURE function: it never talks to a provider. The actual LLM call
|
|
// is injected via CompactionOptions.Summarize so it stays trivially testable and
|
|
// the agent loop owns the provider wiring.
|
|
|
|
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
|
// when the caller does not specify a preserve count. Kept in sync with the replay
|
|
// reconstruction default in internal/sessions/replay.go.
|
|
const defaultCompactionPreserveLast = 6
|
|
|
|
// compactionTriggerRatio is the fraction of the context window at which
|
|
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
|
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
|
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
|
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
|
// summarization, raise it back toward 0.8.
|
|
const compactionTriggerRatio = 0.7
|
|
|
|
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
|
// transcript (and so tests can assert on it).
|
|
const summaryLabel = "[Summary of earlier conversation]"
|
|
|
|
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
|
// and manually requested compaction summarizers.
|
|
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
|
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
|
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
|
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
|
"If the conversation already begins with an earlier summary block, treat its facts as established context and carry them " +
|
|
"forward into the new summary — never drop earlier information."
|
|
|
|
// CompactionOptions configure a single Compact call.
|
|
type CompactionOptions struct {
|
|
// PreserveLast is the number of trailing messages to keep verbatim. The
|
|
// preserved suffix is widened (never shrunk) so it begins at a safe
|
|
// user/assistant boundary. <= 0 falls back to defaultCompactionPreserveLast.
|
|
PreserveLast int
|
|
// Summarize turns the to-be-elided middle into a single dense summary. It is
|
|
// injected so Compact stays pure and testable; the agent loop wires it to a
|
|
// real provider call.
|
|
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
|
// taskState is a snapshot supplied by the running agent. Its immutable
|
|
// objective is always preserved; mutable fields are admitted only when its
|
|
// plan projection still matches the transcript.
|
|
taskState *taskStateSnapshot
|
|
}
|
|
|
|
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
|
type CompactionResult struct {
|
|
// Messages is the original conversation or the compacted replacement.
|
|
Messages []zeroruntime.Message
|
|
// RemovedCount is the number of original messages summarized away.
|
|
RemovedCount int
|
|
// PreservedCount is the number of original messages kept verbatim, including
|
|
// leading system messages and the preserved suffix.
|
|
PreservedCount int
|
|
// SummaryText is the trimmed text returned by the summarizer. The injected
|
|
// summary message also includes summaryLabel and any preserved structured
|
|
// state needed by later compactions.
|
|
SummaryText string
|
|
// ProjectedChars is the text content sent to the summarizer after semantic
|
|
// projection, excluding provider protocol framing.
|
|
ProjectedChars int
|
|
// Truncated reports whether projection dropped content to satisfy its
|
|
// bounded brief or previous-summary limits.
|
|
Truncated bool
|
|
// Compacted reports whether Messages contains an injected summary.
|
|
Compacted bool
|
|
}
|
|
|
|
// imageTokenEstimate is a flat per-image token cost. Vision models tokenize an
|
|
// image by its resolution (tiles), not its byte length, so the raw Data size is
|
|
// a poor proxy — counting len(Data)/4 would overcount by orders of magnitude.
|
|
// A fixed estimate keeps estimateTokens monotonic in the image count so an
|
|
// image-heavy context still trends toward compaction instead of reading as ~0.
|
|
// ~1k tokens is a representative mid-range cost across providers.
|
|
const imageTokenEstimate = 1000
|
|
|
|
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
|
|
// The BPE tokenizers zero targets fold a run's leading space into the following
|
|
// token rather than emitting whitespace as its own token, so naive len/4
|
|
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
|
|
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
|
|
// the provider's actual count closely (validated against live usage) while
|
|
// staying allocation- and dependency-free. zero still receives the exact count
|
|
// back as usage on every request; this estimate is only for the pre-request
|
|
// context budget preview and the compaction threshold.
|
|
func ApproxTextTokens(value string) int {
|
|
nonSpace := 0
|
|
for i := 0; i < len(value); i++ {
|
|
switch value[i] {
|
|
case ' ', '\t', '\n', '\r', '\f', '\v':
|
|
default:
|
|
nonSpace++
|
|
}
|
|
}
|
|
return nonSpace / 4
|
|
}
|
|
|
|
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
|
|
// across message content plus tool call names/arguments and a flat per-image
|
|
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
|
|
// and roughly proportional so the loop can decide when to compact.
|
|
func estimateTokens(messages []zeroruntime.Message) int {
|
|
total := 0
|
|
for _, message := range messages {
|
|
total += ApproxTextTokens(message.Content)
|
|
for _, call := range message.ToolCalls {
|
|
total += ApproxTextTokens(call.Name)
|
|
total += ApproxTextTokens(call.Arguments)
|
|
total += 4 // small per-call overhead
|
|
}
|
|
total += len(message.Images) * imageTokenEstimate
|
|
total += 4 // per-message overhead
|
|
}
|
|
return total
|
|
}
|
|
|
|
// estimateToolDefTokens approximates the input-token cost of the tool definitions
|
|
// sent with every request (~4 chars/token, matching estimateTokens). Compaction
|
|
// must include them: they ride on every turn, so ignoring them under-counts the
|
|
// real context and can let it blow past the model limit while the message-only
|
|
// estimate still looks under threshold.
|
|
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
|
total := 0
|
|
for _, tool := range tools {
|
|
total += ApproxTextTokens(tool.Name)
|
|
total += ApproxTextTokens(tool.Description)
|
|
total += ApproxTextTokens(string(tool.Type))
|
|
if len(tool.Parameters) > 0 {
|
|
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
|
total += ApproxTextTokens(string(encoded))
|
|
}
|
|
}
|
|
if tool.Format != nil {
|
|
total += ApproxTextTokens(tool.Format.Type)
|
|
total += ApproxTextTokens(tool.Format.Syntax)
|
|
total += ApproxTextTokens(tool.Format.Definition)
|
|
}
|
|
total += 4 // per-tool overhead
|
|
}
|
|
return total
|
|
}
|
|
|
|
// compactionThreshold is the estimated-token level at which proactive
|
|
// compaction triggers for a given context window.
|
|
func compactionThreshold(contextWindow int) int {
|
|
if contextWindow <= 0 {
|
|
return 0
|
|
}
|
|
return int(float64(contextWindow) * compactionTriggerRatio)
|
|
}
|
|
|
|
// Compact summarizes the oldest middle of a conversation, keeping the leading
|
|
// system message(s) and the most recent turns verbatim. The result is:
|
|
//
|
|
// [system..., summaryAsUser, preservedSuffix...]
|
|
//
|
|
// Rules:
|
|
// - Leading system messages stay at the head untouched.
|
|
// - The preserved suffix is widened backward so it never begins with a
|
|
// tool/tool_result message (provider APIs reject a dangling tool result
|
|
// with no preceding assistant tool call).
|
|
// - The summary is injected as a single user-role message labeled with
|
|
// summaryLabel.
|
|
// - If there is nothing to summarize (too few messages once system and the
|
|
// preserved suffix are removed), the input is returned unchanged.
|
|
//
|
|
// Compact is pure: it performs no provider I/O. A Summarize error is returned to
|
|
// the caller, which decides how to recover.
|
|
func Compact(messages []zeroruntime.Message, opts CompactionOptions) ([]zeroruntime.Message, error) {
|
|
result, err := CompactMessages(messages, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Messages, nil
|
|
}
|
|
|
|
// CompactMessages summarizes the oldest middle of a conversation and returns
|
|
// both the replacement messages and UI/session-friendly metadata about what
|
|
// changed. It uses the same compaction rules as Compact.
|
|
func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (CompactionResult, error) {
|
|
preserveLast := opts.PreserveLast
|
|
if preserveLast <= 0 {
|
|
preserveLast = defaultCompactionPreserveLast
|
|
}
|
|
if opts.Summarize == nil {
|
|
return CompactionResult{}, errors.New("compaction requires a Summarize function")
|
|
}
|
|
|
|
// Leading system messages are kept verbatim at the head.
|
|
systemEnd := 0
|
|
for systemEnd < len(messages) && messages[systemEnd].Role == zeroruntime.MessageRoleSystem {
|
|
systemEnd++
|
|
}
|
|
|
|
// Naive boundary: keep the last preserveLast messages. Then widen the suffix
|
|
// backward to a safe boundary so it never starts on a tool result.
|
|
boundary := len(messages) - preserveLast
|
|
if boundary < systemEnd {
|
|
boundary = systemEnd
|
|
}
|
|
boundary = safeSuffixBoundary(messages, systemEnd, boundary)
|
|
|
|
middle := messages[systemEnd:boundary]
|
|
if len(middle) == 0 {
|
|
// Nothing to summarize once system + preserved suffix are removed.
|
|
return CompactionResult{
|
|
Messages: messages,
|
|
PreservedCount: len(messages),
|
|
}, nil
|
|
}
|
|
|
|
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
|
if err != nil {
|
|
return CompactionResult{}, err
|
|
}
|
|
summary := summaryResult.SummaryText
|
|
|
|
// Preserve structured state (active plan + loaded skills) from the elided
|
|
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
|
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
|
|
|
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
|
compacted = append(compacted, messages[:systemEnd]...)
|
|
compacted = append(compacted, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: content,
|
|
})
|
|
compacted = append(compacted, messages[boundary:]...)
|
|
return CompactionResult{
|
|
Messages: compacted,
|
|
RemovedCount: len(middle),
|
|
PreservedCount: len(messages) - len(middle),
|
|
SummaryText: summary,
|
|
ProjectedChars: summaryResult.ProjectedChars,
|
|
Truncated: summaryResult.Truncated,
|
|
Compacted: true,
|
|
}, nil
|
|
}
|
|
|
|
// CompactionSummaryResult describes the shared summarization input and output.
|
|
type CompactionSummaryResult struct {
|
|
SummaryText string
|
|
ProjectedChars int
|
|
Truncated bool
|
|
}
|
|
|
|
// SummarizeCompactionMessages applies the shared semantic projection before
|
|
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
|
|
// and manual session compaction both use this path.
|
|
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
|
|
if summarize == nil {
|
|
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
|
|
}
|
|
projection := projectCompactionInput(messages)
|
|
summaryInput := projection.messages
|
|
if len(summaryInput) == 0 {
|
|
summaryInput = messages
|
|
}
|
|
summary, err := summarize(summaryInput)
|
|
if err != nil {
|
|
return CompactionSummaryResult{}, err
|
|
}
|
|
return CompactionSummaryResult{
|
|
SummaryText: strings.TrimSpace(summary),
|
|
ProjectedChars: compactionMessageChars(summaryInput),
|
|
Truncated: projection.truncated,
|
|
}, nil
|
|
}
|
|
|
|
func compactionMessageChars(messages []zeroruntime.Message) int {
|
|
total := 0
|
|
for _, message := range messages {
|
|
total += len(message.Content)
|
|
for _, call := range message.ToolCalls {
|
|
total += len(call.Name) + len(call.Arguments)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
|
// the preserved suffix begins on a user or assistant message rather than a
|
|
// tool/tool_result message. A tool result with no preceding assistant tool call
|
|
// is rejected by provider APIs, so the boundary must land on a safe turn start.
|
|
// It never moves the boundary forward (the suffix only grows), and never crosses
|
|
// systemEnd.
|
|
func safeSuffixBoundary(messages []zeroruntime.Message, systemEnd int, boundary int) int {
|
|
// Walk back so the preserved suffix begins with an assistant message. The
|
|
// summary is injected as a user-role message, so a user- or tool-led suffix
|
|
// would create consecutive same-role turns that strict providers (Anthropic)
|
|
// reject. Stopping on an assistant keeps user/assistant alternation valid;
|
|
// if no assistant exists above systemEnd, boundary lands at systemEnd and the
|
|
// middle is empty, so Compact no-ops (no summary is injected).
|
|
for boundary > systemEnd && messages[boundary].Role != zeroruntime.MessageRoleAssistant {
|
|
boundary--
|
|
}
|
|
return boundary
|
|
}
|
|
|
|
// isContextLimitError reports whether a provider error string looks like a
|
|
// context-window / prompt-too-long error from a common provider. Matching is
|
|
// substring-based and case-insensitive so it tolerates phrasing differences
|
|
// across OpenAI, Anthropic, and Google providers.
|
|
func isContextLimitError(message string) bool {
|
|
lowered := strings.ToLower(strings.TrimSpace(message))
|
|
if lowered == "" {
|
|
return false
|
|
}
|
|
needles := []string{
|
|
"context length",
|
|
"context window",
|
|
"context_length_exceeded",
|
|
"maximum context",
|
|
"context limit",
|
|
"prompt is too long",
|
|
"too many tokens",
|
|
"reduce the length of the messages",
|
|
"exceeds the maximum",
|
|
"input is too long",
|
|
}
|
|
for _, needle := range needles {
|
|
if strings.Contains(lowered, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isStreamTimeoutError reports whether a streamed error is a provider stream
|
|
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
|
|
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
|
|
// no content was forwarded yet it can be safely re-issued on a fresh connection
|
|
// (see the stall-retry in the agent loop). Substring + case-insensitive to
|
|
// tolerate the prefixed "provider stream error: …" wrapping.
|
|
func isStreamTimeoutError(message string) bool {
|
|
lowered := strings.ToLower(strings.TrimSpace(message))
|
|
if lowered == "" {
|
|
return false
|
|
}
|
|
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
|
|
if strings.Contains(lowered, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// compactionState carries the per-run state the agent loop needs to compact a
|
|
// conversation safely. It is created once per Run and is a no-op whenever
|
|
// options.ContextWindow <= 0.
|
|
type compactionState struct {
|
|
enabled bool
|
|
threshold int
|
|
preserveLast int
|
|
// lowWaterMark is the estimated token size at (or below) which we will NOT
|
|
// proactively compact again. It is the size right after the last compaction;
|
|
// the loop only compacts when the history has grown past it AND is over the
|
|
// threshold. This prevents compacting on every turn once near the limit.
|
|
lowWaterMark int
|
|
// reactiveAttempted guards the reactive path so it fires at most once per
|
|
// run. Without this a provider that keeps returning context-limit errors
|
|
// (even after compaction) could loop indefinitely; one attempt then the
|
|
// original error surfaces.
|
|
reactiveAttempted bool
|
|
// onUsage forwards the summarizer call's token usage for accounting/budgeting.
|
|
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
|
// but its token COST must still be counted so usage reports and budgets include it.
|
|
onUsage func(Usage)
|
|
task *taskState
|
|
planner *contextPlanner
|
|
trace *trace.Recorder
|
|
|
|
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
|
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
|
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
|
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
|
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
|
calibrationRatio float64
|
|
|
|
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
|
// (Options.Summarizer) for the current main model. summarizerProvider
|
|
// memoizes the built provider and summarizerResolved records that the
|
|
// factory ran (it may legitimately return no provider: the main model is
|
|
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
|
// build or call fails, the run stays on the main provider until the next
|
|
// model switch — a misconfigured cheap model must never break (or
|
|
// repeatedly delay) compaction.
|
|
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
|
summarizerProvider Provider
|
|
summarizerResolved bool
|
|
summarizerBroken bool
|
|
// window is the context window the threshold was derived from. The
|
|
// reactive free-prune stage compares against it (the limit the provider
|
|
// enforces), not against the 70% proactive threshold — a pruned history
|
|
// between the two would still succeed on retry.
|
|
window int
|
|
// mainModel is the model the run currently streams on; the summarizer
|
|
// choice is made against it and remade after a mid-run switch.
|
|
mainModel string
|
|
}
|
|
|
|
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
|
// running calibration ratio. A single sample is clamped to a sane band so an
|
|
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
|
// estimate enough to disable or thrash compaction.
|
|
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
|
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
|
return
|
|
}
|
|
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
|
if sample < 0.5 {
|
|
sample = 0.5
|
|
} else if sample > 2.0 {
|
|
sample = 2.0
|
|
}
|
|
if state.calibrationRatio <= 0 {
|
|
state.calibrationRatio = 1.0
|
|
}
|
|
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
|
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
|
}
|
|
|
|
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
|
// arrives (ratio unset) it returns the raw estimate unchanged.
|
|
func (state *compactionState) calibratedTokens(raw int) int {
|
|
if state.calibrationRatio <= 0 {
|
|
return raw
|
|
}
|
|
return int(float64(raw) * state.calibrationRatio)
|
|
}
|
|
|
|
func newCompactionState(options Options, task *taskState) *compactionState {
|
|
state := &compactionState{
|
|
enabled: options.ContextWindow > 0,
|
|
threshold: compactionThreshold(options.ContextWindow),
|
|
window: options.ContextWindow,
|
|
mainModel: options.Model,
|
|
preserveLast: options.CompactionPreserveLast,
|
|
onUsage: options.OnUsage,
|
|
task: task,
|
|
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
|
trace: options.Trace,
|
|
summarizerFactory: options.Summarizer,
|
|
}
|
|
return state
|
|
}
|
|
|
|
// updateWindow re-derives the compaction threshold after a mid-run model
|
|
// switch. The low-water mark is reset: it was measured against the old
|
|
// threshold, and keeping it could suppress a compaction the smaller window
|
|
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
|
func (state *compactionState) updateWindow(window int) {
|
|
if window <= 0 {
|
|
return
|
|
}
|
|
state.enabled = true
|
|
state.window = window
|
|
state.threshold = compactionThreshold(window)
|
|
state.lowWaterMark = 0
|
|
if state.planner != nil {
|
|
state.planner.config.contextWindow = window
|
|
}
|
|
}
|
|
|
|
// switchModel records a mid-run model switch: the window is re-derived
|
|
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
|
// the next compaction re-resolves it against the new main model. A run that
|
|
// started on the cheap model (no dedicated summarizer) and escalated to an
|
|
// expensive one therefore regains cheap summaries — the exact workflow a
|
|
// dedicated summarizer exists for.
|
|
func (state *compactionState) switchModel(modelID string, window int) {
|
|
state.mainModel = modelID
|
|
state.updateWindow(window)
|
|
state.summarizerProvider = nil
|
|
state.summarizerResolved = false
|
|
state.summarizerBroken = false
|
|
}
|
|
|
|
// summarizeProvider returns the provider summarization calls should use and
|
|
// whether it is the dedicated summarizer: that one when it applies to the
|
|
// current main model and is healthy, else the main provider. The factory runs
|
|
// at most once per main model; a build error marks the summarizer broken for
|
|
// that model. Callers branch on the boolean rather than comparing Provider
|
|
// values — interface comparison panics on a non-comparable dynamic type.
|
|
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
|
if state.summarizerFactory == nil || state.summarizerBroken {
|
|
return main, false
|
|
}
|
|
if !state.summarizerResolved {
|
|
state.summarizerResolved = true
|
|
built, err := state.summarizerFactory(ctx, state.mainModel)
|
|
if err != nil {
|
|
state.summarizerBroken = true
|
|
return main, false
|
|
}
|
|
state.summarizerProvider = built
|
|
}
|
|
if state.summarizerProvider == nil {
|
|
return main, false
|
|
}
|
|
return state.summarizerProvider, true
|
|
}
|
|
|
|
// fitsWindow reports whether a calibrated size is safely under the limit the
|
|
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
|
// for the paid summarizer, not the hard limit; the reactive path compares
|
|
// against the window itself, with a small margin for estimate error, so a
|
|
// history pruned to, say, 85% of the window takes the free retry.
|
|
func (state *compactionState) fitsWindow(size int) bool {
|
|
if state.window <= 0 {
|
|
return size <= state.threshold
|
|
}
|
|
return size <= state.window-state.window/20
|
|
}
|
|
|
|
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
|
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
|
// when the history is under threshold, or when the history has not grown past
|
|
// the low-water mark since the last compaction (the infinite-loop guard).
|
|
func (state *compactionState) maybeCompact(
|
|
ctx context.Context,
|
|
provider Provider,
|
|
messages []zeroruntime.Message,
|
|
tools []zeroruntime.ToolDefinition,
|
|
) []zeroruntime.Message {
|
|
if !state.enabled {
|
|
return messages
|
|
}
|
|
// Tool definitions are part of every request's input, so count them alongside
|
|
// the messages; both the threshold check and the shrink check below use the
|
|
// same term so they stay consistent.
|
|
toolTokens := estimateToolDefTokens(tools)
|
|
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
if size <= state.threshold {
|
|
return messages
|
|
}
|
|
// Only compact when the history has grown past where we last left it. This
|
|
// stops the loop from re-summarizing an already-compacted history every turn
|
|
// when it sits just over the threshold.
|
|
if state.lowWaterMark > 0 && size <= state.lowWaterMark {
|
|
return messages
|
|
}
|
|
|
|
// CHEAP FIRST STAGE: reclaim context at zero token/latency cost by pruning
|
|
// the bodies of old, large tool results (the model has already acted on
|
|
// them). If that brings us back under threshold, skip the paid summarizer
|
|
// entirely and preserve recent turns verbatim.
|
|
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
|
messages = pruned
|
|
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
if size <= state.threshold {
|
|
state.lowWaterMark = size
|
|
return messages
|
|
}
|
|
}
|
|
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
|
messages = pruned
|
|
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
if size <= state.threshold {
|
|
state.lowWaterMark = size
|
|
return messages
|
|
}
|
|
}
|
|
|
|
compacted, err := Compact(messages, CompactionOptions{
|
|
PreserveLast: state.preserveLast,
|
|
Summarize: state.summarizeClosure(ctx, provider),
|
|
taskState: state.task.snapshotForCompaction(messages),
|
|
})
|
|
if err != nil {
|
|
// Summarizer failed: keep the original history. The reactive path (or a
|
|
// later turn) can try again; we never drop messages on failure here.
|
|
return messages
|
|
}
|
|
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
|
if newSize >= size {
|
|
// Compaction did not actually shrink anything (e.g. nothing to
|
|
// summarize). Leave the history untouched and don't churn next turn.
|
|
state.lowWaterMark = size
|
|
return messages
|
|
}
|
|
// Only count a compaction when it actually shrank the history, so the
|
|
// compaction counter reflects real context reductions rather than paid
|
|
// no-ops that left the token budget untouched.
|
|
if r := trace.FromContext(ctx); r != nil {
|
|
r.Counter(trace.CounterCompactionCount, 1)
|
|
}
|
|
state.lowWaterMark = newSize
|
|
return compacted
|
|
}
|
|
|
|
// recover runs reactive compaction after a provider/stream error. It compacts
|
|
// at most once per run when the error looks like a context-limit error and the
|
|
// history can actually be shrunk. The returned booleans are (compacted, retried) and the
|
|
// error is non-nil only when compaction itself failed (so the loop should give
|
|
// up). When retried is false the caller keeps its original error.
|
|
func (state *compactionState) recover(
|
|
ctx context.Context,
|
|
provider Provider,
|
|
messages []zeroruntime.Message,
|
|
tools []zeroruntime.ToolDefinition,
|
|
errorMessage string,
|
|
) (compacted []zeroruntime.Message, retried bool, err error) {
|
|
if !state.enabled {
|
|
// Compaction disabled (ContextWindow==0): stay a strict no-op so a
|
|
// context-limit error never triggers an unexpected summarization call.
|
|
return messages, false, nil
|
|
}
|
|
if !isContextLimitError(errorMessage) {
|
|
return messages, false, nil
|
|
}
|
|
|
|
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
|
// often be fixed for free by dropping superseded reads and pruning old
|
|
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
|
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
|
// prune). The free retry is taken only when the pruned history fits the
|
|
// window the provider enforces — otherwise a retry would fail the same
|
|
// way, so fall through to the paid summarizer in this same call.
|
|
toolTokens := estimateToolDefTokens(tools)
|
|
prunedAny := false
|
|
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
|
messages, prunedAny = pruned, true
|
|
}
|
|
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
|
messages, prunedAny = pruned, true
|
|
}
|
|
if prunedAny {
|
|
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
if state.fitsWindow(size) {
|
|
state.lowWaterMark = size
|
|
return messages, true, nil
|
|
}
|
|
}
|
|
|
|
if state.reactiveAttempted {
|
|
return messages, false, nil
|
|
}
|
|
|
|
result, compactErr := Compact(messages, CompactionOptions{
|
|
PreserveLast: state.preserveLast,
|
|
Summarize: state.summarizeClosure(ctx, provider),
|
|
taskState: state.task.snapshotForCompaction(messages),
|
|
})
|
|
if compactErr != nil {
|
|
// A genuine compaction attempt was made (and failed): the budget is spent
|
|
// so the loop gives up rather than retrying a failing summarizer forever.
|
|
state.reactiveAttempted = true
|
|
return messages, true, compactErr
|
|
}
|
|
if estimateTokens(result) >= estimateTokens(messages) {
|
|
// Nothing to compact; the retry would just fail again. Signal "not
|
|
// retried" so the caller surfaces the original context-limit error. Do NOT
|
|
// consume the one-shot budget here: a no-op recover (history too small to
|
|
// shrink) must not disable a later recovery once the history has grown.
|
|
return messages, false, nil
|
|
}
|
|
// Success: a real compaction shrank the history and we will retry. Consume the
|
|
// one-shot budget now so a provider that keeps returning context-limit errors
|
|
// after a successful compaction can't loop forever. Store the low-water mark in
|
|
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
|
// proactive shrink-guard compares like with like. Count it now that the shrink
|
|
// is confirmed, so the counter mirrors maybeCompact's real-reduction policy.
|
|
if r := trace.FromContext(ctx); r != nil {
|
|
r.Counter(trace.CounterCompactionCount, 1)
|
|
}
|
|
state.reactiveAttempted = true
|
|
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
|
return result, true, nil
|
|
}
|
|
|
|
// summarizeClosure builds a Summarize function backed by a focused, tool-less
|
|
// provider call. The summary stream intentionally does NOT forward OnText (so
|
|
// compaction stays invisible on the user-facing surface), but it DOES forward
|
|
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
|
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
|
// call fails the summarizer is marked broken for the current model and the
|
|
// same slice is retried once on the main provider, so the caller sees at most
|
|
// one (main-provider-quality) error.
|
|
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
|
if state.planner == nil {
|
|
state.planner = newContextPlanner(contextPlannerConfig{})
|
|
}
|
|
return func(toSummarize []zeroruntime.Message) (string, error) {
|
|
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
|
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
|
if err == nil || !dedicated {
|
|
return summary, err
|
|
}
|
|
state.summarizerBroken = true
|
|
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
|
}
|
|
}
|
|
|
|
// summarizeWithFallback summarizes messages in a single provider call. If that
|
|
// call fails with a context-limit error — the slice to summarize is itself too
|
|
// large for the model's input window — it splits the slice in half, summarizes
|
|
// each half recursively, and joins the partial summaries. This keeps compaction
|
|
// working when the elided middle is bigger than the summarizer's own context.
|
|
// Non-context-limit errors (and a single message that still won't fit) surface
|
|
// to the caller unchanged.
|
|
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
|
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
|
}
|
|
|
|
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
|
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
|
|
if err == nil {
|
|
return summary, nil
|
|
}
|
|
if len(messages) < 2 || !isContextLimitError(err.Error()) {
|
|
return "", err
|
|
}
|
|
|
|
mid := len(messages) / 2
|
|
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
|
if leftErr != nil {
|
|
return "", leftErr
|
|
}
|
|
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
|
if rightErr != nil {
|
|
return "", rightErr
|
|
}
|
|
|
|
// Re-summarize the two partial summaries into ONE so the persisted summary
|
|
// stays a single, further-summarizable unit — not an ever-growing concatenated
|
|
// blob that a later compaction (which can't split a single message) would fail
|
|
// on. If even the combined partials don't fit (extreme), fall back to the
|
|
// joined text: still better than failing, and each half is already compacted.
|
|
combined := strings.TrimSpace(left + "\n\n" + right)
|
|
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
|
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
|
}, onUsage, planner, recorder)
|
|
if reduceErr != nil {
|
|
if isContextLimitError(reduceErr.Error()) {
|
|
// Even the two combined partials don't fit (extreme): fall back to the
|
|
// joined text — still better than failing, and each half is already
|
|
// compacted.
|
|
return combined, nil
|
|
}
|
|
// A non-context failure (auth/network/provider) must surface unchanged, per
|
|
// this function's contract — don't mask it behind the joined fallback.
|
|
return "", reduceErr
|
|
}
|
|
return reduced, nil
|
|
}
|
|
|
|
// summarizeMessagesOnce performs a single tool-less summarization call.
|
|
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
|
requestMessages := []zeroruntime.Message{
|
|
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
|
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
|
}
|
|
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
|
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
|
recordContextPlanTrace(recorder, plan)
|
|
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Forward OnUsage (token accounting) but not OnText (keep compaction invisible
|
|
// to the user); a nil onUsage is a no-op.
|
|
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{OnUsage: onUsage})
|
|
if collected.Error != "" {
|
|
return "", errors.New(collected.Error)
|
|
}
|
|
summary := strings.TrimSpace(collected.Text)
|
|
if summary == "" {
|
|
return "", errors.New("summarizer returned no text")
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
|
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
|
// search results), yet the summary instructions only need their gist — the
|
|
// model that ACTED on the full output already turned it into decisions the
|
|
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
|
// head+tail of a result while cutting the summarizer input by 5-10x on
|
|
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
|
const summaryToolResultClamp = 2000
|
|
|
|
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
|
// a write_file call carries the whole file body in its arguments.
|
|
const summaryToolArgsClamp = 500
|
|
|
|
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
|
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
|
func clampForSummary(text string, max int) string {
|
|
if max <= 0 || len(text) <= max {
|
|
return text
|
|
}
|
|
head := max * 3 / 4
|
|
tail := max - head
|
|
for head > 0 && !utf8.RuneStart(text[head]) {
|
|
head--
|
|
}
|
|
start := len(text) - tail
|
|
for start < len(text) && !utf8.RuneStart(text[start]) {
|
|
start++
|
|
}
|
|
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
|
}
|
|
|
|
// renderTranscript flattens messages into a plain-text transcript for the
|
|
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
|
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
|
// above): the summary needs what was done and decided, not the raw payloads.
|
|
func renderTranscript(messages []zeroruntime.Message) string {
|
|
lines := make([]string, 0, len(messages))
|
|
for _, message := range messages {
|
|
switch message.Role {
|
|
case zeroruntime.MessageRoleAssistant:
|
|
line := "assistant: " + message.Content
|
|
if len(message.ToolCalls) > 0 {
|
|
calls := make([]string, 0, len(message.ToolCalls))
|
|
for _, call := range message.ToolCalls {
|
|
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
|
}
|
|
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
|
}
|
|
lines = append(lines, line)
|
|
case zeroruntime.MessageRoleTool:
|
|
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
|
default:
|
|
lines = append(lines, string(message.Role)+": "+message.Content)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n\n")
|
|
}
|