mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* perf(agent): cheap compaction — dedicated summarizer model, input caps, free reactive prune, window re-derive
Compaction summarization was the single most expensive recurring event
in a long run: the full elided middle — verbatim tool bodies included —
went to the session's main model at full price (a ~100k-token call near
a 200k window, recursively multiplied on overflow).
Four changes, each independently safe:
- Dedicated summarizer model. Compaction summaries route to a cheap
model resolved as ZERO_COMPACTION_MODEL > preferences.compactionModel
> a curated default on official endpoints (Haiku 4.5 / Gemini
Flash-Lite / gpt-4.1-mini; custom-endpoint catalogs are unknowable so
they get none). Built lazily on the first paid compaction; ANY
failure permanently falls back to the main provider for the run, so a
misconfigured summarizer can never break compaction. "main" at any
level forces the old behavior.
- Summarizer input caps. renderTranscript clamps tool-result bodies to
2,000 chars and tool-call arguments to 500 (head+tail, rune-safe) —
the summary needs what was done and decided, not raw payloads. Cuts
summarizer input 5-10x on tool-heavy sessions.
- Free reactive prune. recover() now runs the same prune-first stage as
the proactive path: a context-limit error is first answered by
pruning stale tool bodies at zero cost, without consuming the
one-shot reactive budget; the paid summarizer only runs when a later
error finds nothing left to prune.
- Window re-derivation on model switch. Options.ContextWindowFor
(wired to the model registry in exec and the TUI) lets the loop
re-derive the compaction threshold after escalate_model, closing the
documented KNOWN LIMITATION where a switch to a smaller-window model
could overflow and a larger one over-compacted.
## Rebase notes (2026-08-25, onto 2749447c)
Rebased across 214 commits. main now summarizes through the context planner
(#879) and switches models via turn sessions, so the agent side was reworked
rather than merged textually:
- Summarizer selection lives on compactionState and is resolved lazily per
main model through summarizeWithPlanner (planner + trace preserved); a
failing dedicated summarizer is marked broken and the same slice is
retried once on the main provider.
- Options.Summarizer now receives the main model in force:
`func(ctx, mainModelID string) (Provider, error)`, and may return
(nil, nil) when no dedicated model applies. After a successful
escalate_model switch the loop calls compactor.switchModel, which
re-derives the window and re-resolves the summarizer — a run that started
on the cheap model and escalated regains cheap summaries (review P2).
- recover()'s free prune stage runs before the one-shot reactive guard
(pruning is free and cannot loop) and mirrors maybeCompact's two prune
passes; the free retry is taken only when the pruned history fits the
window the provider enforces (with a 5% estimate margin), otherwise the
same call falls through to the paid summarizer. Gating on the 70%
proactive threshold would have sent histories in the 70-95% band to the
summarizer unnecessarily (review P1 and the follow-up question).
- ContextWindowFor is wired through the turn-session switch path; the
planner's window is updated alongside the compactor's.
Tests: the PR's suite is ported to the new seams, plus insufficient-prune
fall-through, free prune after the budget is spent, window-vs-threshold
gating, and summarizer re-resolution across a model switch.
* fix(agent): branch on a dedicated-summarizer flag instead of comparing providers
Review follow-ups for #532:
- summarizeProvider now reports whether the dedicated summarizer was
selected and summarizeClosure branches on that flag; the previous
`chosen == provider` compared Provider interface values, which panics
when the dynamic type is not comparable. Regression test uses a provider
with a slice field for both the summarizer and the main provider.
- CompactionModelID's curated-default test covers the official OpenAI
profile alongside Anthropic and Google.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
---------
Co-authored-by: Claude Fable 5 <[email protected]>
109 lines
4.1 KiB
Go
109 lines
4.1 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// Compaction summarization model selection.
|
|
//
|
|
// Compaction summaries used to run on the session's main model — near a 200k
|
|
// window one summarization is a ~100k-token input call at full price, the
|
|
// single most expensive recurring event in a long run. A cheap model handles
|
|
// the "dense factual summary" task fine, so summarization is routed to one
|
|
// whenever the target is knowable.
|
|
//
|
|
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
|
|
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
|
|
// main model). The literal value "main" at any level forces the main model.
|
|
// A wrong choice is never fatal: the agent falls back to the main provider on
|
|
// the first summarizer failure for the rest of the run.
|
|
|
|
// CompactionModelEnv overrides the compaction summarization model.
|
|
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
|
|
|
|
// compactionMainSentinel disables the dedicated summarizer explicitly.
|
|
const compactionMainSentinel = "main"
|
|
|
|
// Curated cheap summarizer defaults, applied only for provider kinds whose
|
|
// model catalog is guaranteed (official endpoints). Custom / *-compatible
|
|
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
|
|
// failed call per run is worse than main-model prices.
|
|
const (
|
|
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
|
|
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
|
|
defaultOpenAICompactionModel = "gpt-4.1-mini"
|
|
)
|
|
|
|
// CompactionModelID returns the model that compaction summarization calls
|
|
// should use for the given profile, or "" to use the session's main model.
|
|
// preference is the resolved config value (preferences.compactionModel).
|
|
func CompactionModelID(profile config.ProviderProfile, preference string) string {
|
|
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
|
|
if value == "" {
|
|
value = strings.TrimSpace(preference)
|
|
}
|
|
if value != "" {
|
|
if strings.EqualFold(value, compactionMainSentinel) {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
return defaultCompactionModel(profile)
|
|
}
|
|
|
|
func defaultCompactionModel(profile config.ProviderProfile) string {
|
|
metadata, err := ResolveRuntimeMetadata(profile, Options{})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var candidate string
|
|
switch metadata.ProviderKind {
|
|
case config.ProviderKindAnthropic:
|
|
candidate = defaultAnthropicCompactionModel
|
|
case config.ProviderKindGoogle:
|
|
candidate = defaultGoogleCompactionModel
|
|
case config.ProviderKindOpenAI:
|
|
candidate = defaultOpenAICompactionModel
|
|
default:
|
|
return ""
|
|
}
|
|
// Session already runs on the cheap model: a dedicated summarizer buys
|
|
// nothing and would only add a second provider instance.
|
|
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
|
|
return ""
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
// CompactionSummarizerFactory returns the lazy constructor the agent loop
|
|
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
|
|
// The compactor passes the main model in force at that moment — on the first
|
|
// paid compaction and again after a mid-run model switch — so the choice
|
|
// tracks escalations: a run that starts on the cheap model gets no dedicated
|
|
// summarizer, and gains one once it escalates to a pricier main model.
|
|
// Returning (nil, nil) means "summarize on the main provider". The factory
|
|
// itself is nil only when no provider builder exists. Shared by the exec and
|
|
// TUI front ends so the selection rules cannot drift between them.
|
|
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
|
|
if newProvider == nil {
|
|
return nil
|
|
}
|
|
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
|
|
profile := base
|
|
if strings.TrimSpace(mainModelID) != "" {
|
|
profile.Model = mainModelID
|
|
}
|
|
modelID := CompactionModelID(profile, preference)
|
|
if modelID == "" {
|
|
return nil, nil
|
|
}
|
|
profile.Model = modelID
|
|
return newProvider(profile)
|
|
}
|
|
}
|