mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(tui): actionable hints on provider/model errors Classify a failed turn's provider error into auth / rate-limit / connectivity / model-not-found / context-overflow and render a one-line next step below the red error row, instead of dumping an identical raw blob for every failure mode. The classifier lives in a new internal/errhint package so both the TUI error row and the CLI exec path can share it (TUIHint references slash commands, CLIHint references zero subcommands). Classification is a conservative string heuristic keyed off providerio.ClassifiedError's prefixes plus lower-level DNS/TLS/timeout/context-length signatures, since the numeric HTTP status is gone by the time the error reaches a UI surface. * feat(tools): budget bash output with head+tail truncation bash was the one tool that returned command output with no byte cap — 'cat large.log' or a verbose test run could dump megabytes into context and force compaction, while every other read/search tool already applies a budget. Cap stdout and stderr at 96KB each, keeping the head and tail of an oversized stream (build/test failures usually land at the tail) and dropping the middle behind a marker that points at redirect-to-file + read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in Meta like the other tools. Sandbox-denial and shell-issue detection still run on the full raw output before budgeting. * fix(gemini): retry 401 with refreshed OAuth token The Gemini provider used providerio.SendWithRetry (429/503/529 only), so an expired OAuth token surfaced as a terminal 'API key not valid' auth error while OpenAI and Anthropic silently recovered via SendWithAuthRetry's force-refresh-and-replay on 401. Plumb OAuthResolver through gemini.Options/Provider and the factory (exactly as the other two providers already do) and switch the stream send to SendWithAuthRetry. With a nil resolver (API-key users) behavior is unchanged. * fix(errhint): gate classification on provider-origin marker Address CodeRabbit review on #1: agentResponseMsg.err can also carry local failures (a tool's 'permission denied', a 'file does not exist', a config error), and broad substrings would have attached a bogus /provider or /model hint to those. Classify now returns Unknown unless the message carries a provider marker the provider layer always attaches (auth error: / rate limit error: / provider error: / provider request error: / provider stream error:). Local errors never draw a provider hint; provider errors sub-classify as before. Added local-failure test cases proving they stay Unknown. * feat(cli): actionable hint on text-mode exec provider errors zero exec printed provider failures as a bare '[zero] <raw error>' with no next step. Append a one-line hint (reusing the shared errhint classifier) for recognized provider failures — 'run `zero auth`', 'run `zero doctor`', etc. The provider-origin gate means non-provider codes (sandbox_error, mcp_error) and JSON/stream-json output are untouched. * fix(tools): set Result.Truncated for budgeted bash output Address CodeRabbit review on #3: budgetBashOutput recorded truncation in meta["truncated"] but never set the Result.Truncated struct field, so consumers reading the struct (like glob/web_fetch/read_minified do) would miss truncated bash results. Return the bool from budgetBashOutput and set Result.Truncated on every return path. * perf(tui): reuse one LSP manager across prompts runAgentWithOptions built a fresh lsp.NewManager per run and shut it down when the run returned, so gopls (and every other language server) cold-started on the first edit of every turn — 200ms-2s of latency the manager's own design (long-lived, reused servers) exists to avoid. Build one manager per session in newModel (cheap: servers start lazily on first Check) and reuse it across runs; the SelfCorrector still gets a fresh checker wrapping the shared manager each run. Torn down with a short deadline in quit(). Runs fall back to a per-run manager when the session manager is absent (cwd unknown, or a directly-built test model). * perf(tui): accumulate streamingText as []byte (O(1) append) m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long generation (a 10k-token code gen allocates hundreds of MB of intermediate strings and the UI gets progressively laggier). Accumulate into a []byte with append (O(1) amortized) and read via streamingTextString(). A []byte rather than strings.Builder because the TUI model is copied by value on every Update, which would trip strings.Builder's copy check; []byte is nil-safe and copies cleanly like the other slice fields. * perf(tui): coalesce streamed text deltas to one frame Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s) drove 100+ full Update->View cycles per second, each re-parsing the growing markdown — visible stutter over SSH and wasted CPU. Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and forward them as a single message, decoupling render rate from token rate. Any non-text message flushes pending text first so ordering with tool-call / reasoning / row messages is preserved; the final agentResponseMsg (a tea.Cmd return, not a sink message) is safe because the model already drops deltas for an inactive runID. * fix(agent): raise tool-failure stop threshold 4->6 guardrails.go halted a run after 4 consecutive same-error tool failures. A corrective hint fires at 2, and a model iterating on a genuinely tricky edit can legitimately fail a couple more times after the hint while converging — stopping at 4 cut those runs short. Raise to 6; the streak still resets the instant the tool succeeds or hits a different error, so only true same-error loops are affected. * fix(agent): more reconnect retries with jitter; narrow to transport errors #19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the exponential backoff (capped at 8s), so a multi-second network blip is ridden out instead of killing the run on a 2s hiccup, and concurrent runs (swarms, cron fleets) don't reconnect in lockstep. #34: drop the '502'/'503' substring matches from shouldReconnect. 503 already exhausted providerio.SendWithRetry (retrying here is a redundant double-retry) and 502 is non-idempotent by providerio's rule (the POST may have been processed). Only genuine transport failures — where no response was received — reconnect now. * fix(agent): reduce stall retries 2->1 to bound stuck-session time A no-output stream stall is detected only after the full stream idle timeout (~5min) elapses, and each retry can idle again — so 2 retries left an interactive session frozen for ~15min. Drop to 1 retry: keeps the common single-hiccup recovery while bounding the worst case to ~2x the idle timeout. * fix(tui): serialize coalescer forwarding to preserve message order Address CodeRabbit review on #7: flush() and the run-switch path drained the text buffer under c.mu but called forward() after releasing it, so a timer-fired text flush could race a concurrent non-text send() and land after the tool-call/reasoning message it should precede. Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever holds the lock delivers atomically and the other caller blocks until it is done — text can never overtake a following non-text message. Added a concurrency stress test (timer racing an inline send) that passes under -race. * feat(tui): accept /compact now Bare /compact already triggers compaction, but /compact now — what users reach for when the context gauge climbs — hit the usage-error branch. Accept the 'now' keyword and advertise it in the command usage. * feat(tui): add /retry, /edit, /copy, /export commands /retry resends the last prompt; /edit recalls it into the composer to tweak and resend — both read a new lastPrompt field captured verbatim (pre-expansion) in launchPrompt. /copy puts the last answer on the clipboard via the existing copy machinery; /export writes a plain role-prefixed transcript to a file (timestamped default, or a given path). Real gaps for SSH users with no mouse select. * feat(tools): separator-insensitive tool_search matching tool_search ranked deferred tools by exact substring, so a model that typed 'webfetch' (dropping the underscore) matched nothing and looped. Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored below an exact substring match, so precise queries still rank first. * feat(tools): signal ask_user dismissal distinctly from a blank field Empty answers all rendered as '(no answer provided)', so the model couldn't tell a wholesale dismissal (user closed the prompt without answering anything) from one field left blank amid real answers — and might invent a default. FormatAskUserAnswers now flags a full dismissal up front as a skip and marks individual empties '(left blank)' vs '(skipped)'. * feat(tui): fuzzy ranking in model/session pickers The pickers filtered by flat strings.Contains with no ranking, so finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling. Rank matches (exact < prefix < contains < subsequence, reusing fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best match, never split — so the grouped model picker still renders one header per provider. Covers both /model and /resume (shared commandPicker). * perf(agent): cache per-tool schema render across turns partitionTools re-ran the recursive schema->map conversion (schemaToRuntimeMap) for every tool on every turn — redundant, since a tool's advertised name/description/schema is stable for the run. Add a per-run definition cache keyed by tool name (nil-safe; the plain partitionTools entrypoint and tests pass nil for fresh renders). The partitioning itself (visibility, deferral, ordering) still recomputes every turn — it must, because a tool's deferred state can flip mid-run (swarm tools un-defer once a swarm is active). Only the expensive schema render is memoized. tool_search is excluded by its callers (dynamic description) so it never poisons the cache. * perf(agent): calibrate compaction estimate against real prompt tokens The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content ~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true capacity — premature summarizer calls that degrade quality. Each turn now folds (rawEstimate, provider InputTokens) into an EMA calibration ratio, clamped per-sample to a sane band; maybeCompact and the reactive path scale their estimates by it. Turn 1 uses the raw estimate (no data yet); later turns compact near real capacity. * style: gofmt the new agent test files The CI gofmt check flagged the #22/#25 test files (struct-field and switch alignment). No behavior change. * feat(agent): turn-based plan staleness reminder The stale-plan nudge only fired after 10 tool calls since the last update_plan, so a plan that drifts stale across many low-tool-call turns (one call per turn) took many turns to catch — the 'plan set turn 1 stays pending forever' case. Add a turn-count complement: after stalePlanTurnThreshold (8) turns without an update AND with items still pending, fire the same one-shot reminder. Gated on pending items so a fully-completed plan is never nagged; the existing tool-call trigger is unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items' relevance match is deliberately omitted — too false-positive-prone.) * feat(agent): preserve recent-edits summary across compaction Compaction preserved the plan, loaded skills, tool schemas, and project instructions, but not file edits — so after the editing turns were elided the model knew it had changed a file only vaguely, and would re-read to rediscover its own footprint. Extract write_file/edit_file targets from the elided middle with a one-line note from each tool result, merge them across repeated compactions (newer note per path wins), and carry them in the preserved-state JSON (capped at 20 paths / 160 bytes per note). * feat(tui): workspace-scoped /resume + fewer event reads /resume listed sessions from every project globally, and read the full event file for each of them to test for resumable content. Filter the picker (and /resume latest) by the current workspace Cwd, checked BEFORE the per-session read so a large global history no longer pays N full file reads to build one workspace's list; also skip zero-event sessions via metadata without any read. Sessions with no recorded Cwd (older runs) and an unknown current workspace stay visible so nothing is hidden that can't be confidently placed. Explicit /resume <id> still resolves any session. * feat(tui): warn on model switch that drops staged images Staged images were silently dropped at submit when the active model had no vision support — the user only learned after sending. Chips already render above the composer (renderAttachmentChips); add an immediate amber warning line to the /model switch status (both same-provider and cross-provider paths) when images are staged and the newly selected model can't accept them, so the drop is surfaced at switch time. * feat(tui): inline recovery affordance on setup errors A first-run setup error rendered as a bare red 'error: <text>' with no pointer to the fix. Append a faint, stage-tailored recovery line (edit the endpoint/name/key above then Enter, or pick another model) that names only keys the stage's footer confirms, so the guidance is always accurate. Empty for stages whose footer already makes recovery obvious. * fix: address CodeRabbit review (5 findings) - loop.go: recompute the compaction calibration estimate at calibration time, not request-build time, so a reactive-compaction reissue that shrinks the request feeds the EMA the sample that actually completed. - errhint: match bare HTTP status codes (401/403/429/529) only at digit boundaries, so an incidental number ('4290ms', 'id 14015') isn't mis-bucketed as auth/rate-limit. - bash: drop the stray unmatched ']' in the output-truncation marker. - tui /retry: apply the same exiting/compactInFlight guards commandPrompt has, so a retry can't race compactResultMsg's rewrite of session state. - tui /export: write the transcript 0o600 (it may contain echoed secrets), not world/group-readable 0o644. * test(tui): skip export file-mode assertion on Windows os.WriteFile ignores Unix permission bits on Windows and Stat reports 0666, so the 0o600 assertion failed there. The production 0o600 is honored on Linux/macOS (where it matters); guard the check with runtime.GOOS so Windows smoke passes. * fix(tui): align /resume latest filters + case-insensitive workspace match on Windows Address CodeRabbit review on #21: - latestResumableInWorkspace now applies the SAME filters as the picker (skip zero-event metadata and empty/failed runs), so /resume latest can't land on a session the picker intentionally hides. - sessionMatchesWorkspace compares paths case-insensitively on Windows (its filesystem is), so the same workspace spelled with different casing no longer hides resumable sessions; other platforms stay case-sensitive. * fix(tui): /retry resends the last prompt's image/PDF attachments /retry relaunched only lastPrompt's text, but launchPrompt clears the pending image/document queues once a turn is sent. A vision- or PDF-backed prompt that failed would silently retry as text-only and answer a different task. Snapshot the consumed attachments at launch and re-stage them on /retry so launchPrompt rebuilds an identical request (document preamble, images, and the submit-time vision re-check). startNewSession drops the snapshot so a post-/new /retry cannot leak the previous session's attachments. Also correct the OAuthResolver factory comment, which omitted the Gemini provider it is now passed to. * fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test) - reconnect: exclude HTTP 5xx (500/502/503/504) before the transport substring match so "504 Gateway Timeout" no longer slips through on the generic "timeout" needle. A gateway timeout is non-idempotent — the completion POST may already have reached the model — so replaying the connect risked duplicate billable work. Reuses a digit-boundary status matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay non-reconnectable. - tui /edit: re-stage the remembered image/PDF snapshot alongside the recalled prompt text, mirroring the /retry fix — editing a vision- or document-backed prompt no longer silently resends a text-only version. - compaction recent-edits: order edits by LAST edit, not first, and move a re-touched path to the newest position on merge, so the maxRecentEdits tail cap keeps the file the model most recently edited instead of dropping it. Adds mergeRecentEdits (edit-specific) and a capped-list regression. - coalescer test: inject the frame timer via afterFunc so TestCoalescerBatchesDeltas proves buffering deterministically instead of racing the real 16ms wall-clock timer. * fix(tools): bound bash output capture in memory, not just model-visible text Previously stdout/stderr streamed into unbounded bytes.Buffers and were only truncated to the 96 KiB budget AFTER the command finished, so a runaway command (cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer that keeps only the head+tail each stream will surface and discards the middle as it arrives, while counting the true total for the truncation marker. Memory is now bounded to ~head+2×tail per stream regardless of output size. truncateHeadTail is refactored onto a total-aware core so the string path (and its tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though only a bounded slice was held. Adds boundedBuffer/capture unit tests and an end-to-end test that streams ~5× the budget and asserts bounded emission with an accurate raw_bytes.
597 lines
25 KiB
Go
597 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
"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]"
|
|
|
|
// summaryInstructions is the system prompt handed to the summarizer model.
|
|
const summaryInstructions = "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)
|
|
}
|
|
|
|
// 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
|
|
// 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)
|
|
if len(tool.Parameters) > 0 {
|
|
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
|
total += ApproxTextTokens(string(encoded))
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
summary, err := opts.Summarize(middle)
|
|
if err != nil {
|
|
return CompactionResult{}, err
|
|
}
|
|
summary = strings.TrimSpace(summary)
|
|
|
|
// 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)
|
|
|
|
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,
|
|
Compacted: true,
|
|
}, nil
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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) *compactionState {
|
|
return &compactionState{
|
|
enabled: options.ContextWindow > 0,
|
|
threshold: compactionThreshold(options.ContextWindow),
|
|
preserveLast: options.CompactionPreserveLast,
|
|
onUsage: options.OnUsage,
|
|
}
|
|
}
|
|
|
|
// 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 := 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: summarizeClosure(ctx, provider, state.onUsage),
|
|
})
|
|
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
|
|
}
|
|
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 state.reactiveAttempted {
|
|
return messages, false, nil
|
|
}
|
|
if !isContextLimitError(errorMessage) {
|
|
return messages, false, nil
|
|
}
|
|
|
|
result, compactErr := Compact(messages, CompactionOptions{
|
|
PreserveLast: state.preserveLast,
|
|
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
|
})
|
|
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.
|
|
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.
|
|
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
|
return func(toSummarize []zeroruntime.Message) (string, error) {
|
|
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
|
if err == nil {
|
|
return summary, nil
|
|
}
|
|
if len(messages) < 2 || !isContextLimitError(err.Error()) {
|
|
return "", err
|
|
}
|
|
|
|
mid := len(messages) / 2
|
|
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
|
if leftErr != nil {
|
|
return "", leftErr
|
|
}
|
|
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
|
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)
|
|
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)) (string, error) {
|
|
request := zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{
|
|
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
|
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
|
},
|
|
// No tools: this is a plain text summarization call.
|
|
}
|
|
stream, err := provider.StreamCompletion(ctx, 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
|
|
}
|
|
|
|
// renderTranscript flattens messages into a plain-text transcript for the
|
|
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
|
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+"("+call.Arguments+")")
|
|
}
|
|
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
|
}
|
|
lines = append(lines, line)
|
|
case zeroruntime.MessageRoleTool:
|
|
lines = append(lines, "tool result: "+message.Content)
|
|
default:
|
|
lines = append(lines, string(message.Role)+": "+message.Content)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n\n")
|
|
}
|