Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

375 lines
14 KiB
Go

package agent
import (
"context"
"fmt"
"strings"
"sync"
"github.com/zzet/gortex/internal/llm"
"github.com/zzet/gortex/internal/tokens"
)
// summarySentinel prefixes a synthetic frozen-zone note so partitionZones
// can recognise an already-compacted summary on a later turn and keep it
// in the frozen zone (never re-fold it into the compress zone).
const summarySentinel = "[rolling-summary] "
// Default tuning for the rolling compactor. ActiveRounds rounds (an
// assistant tool-call + its tool observation each) at the tail of the
// conversation are always kept verbatim; compaction triggers at
// CompressTriggerTokens and targets CompressTargetTokens for the folded
// summary.
const (
defaultActiveRounds = 4
defaultCompressTriggerTokens = 12000
defaultCompressTargetTokens = 1500
)
// RollingCompactor bounds a long agent tool-loop's conversation by folding
// the middle ("compress") zone of rounds into a single synthetic frozen
// summary message when the conversation grows past a high-water mark.
//
// Three zones (oldest → newest, after the pinned system prompt + first
// user task):
//
// frozen — already-summarized rounds, carried as one synthetic
// RoleAssistant note (summarySentinel-prefixed).
// compress — middle rounds eligible to be folded into the rolling summary.
// active — the most recent ActiveRounds rounds, kept verbatim.
//
// The system prompt (conv[0]) and the first user task (conv[1]) are always
// pinned and never enter any zone.
//
// A nil summarizer disables compaction entirely (Run behaves exactly as it
// did before the compactor existed) — that keeps WithCompactor backward
// compatible.
type RollingCompactor struct {
ActiveRounds int
CompressTriggerTokens int
CompressTargetTokens int
summarizer llm.Provider
// sync forces synchronous-but-throttled compaction. When the agent's
// Run deadline is too short to safely outlive a background summarizer
// round (so the derived ctx would routinely cancel summaries mid-flight),
// the compactor summarizes inline on the compaction turn instead of
// spawning a goroutine. The agent loop already has a bounded step count,
// so a synchronous summarize is bounded.
sync bool
// pending guards the in-flight / completed async summary state.
pendingMu sync.Mutex
inflight bool // a background summarizer goroutine is running
ready bool // a completed summary is waiting to be spliced
summary string // the completed summary text (valid only when ready)
// wg tracks any spawned summarizer goroutine so a returning Run can
// wait it out (after cancelling its ctx) — that guarantees no
// goroutine and no usage attribution leaks across Run boundaries.
wg sync.WaitGroup
}
// NewRollingCompactor builds a compactor over a summarization provider.
// Non-positive tuning values fall back to the package defaults. A nil
// summarizer yields a no-op compactor (compaction disabled).
func NewRollingCompactor(summarizer llm.Provider, activeRounds, trigger, target int) *RollingCompactor {
if activeRounds <= 0 {
activeRounds = defaultActiveRounds
}
if trigger <= 0 {
trigger = defaultCompressTriggerTokens
}
if target <= 0 {
target = defaultCompressTargetTokens
}
return &RollingCompactor{
ActiveRounds: activeRounds,
CompressTriggerTokens: trigger,
CompressTargetTokens: target,
summarizer: summarizer,
}
}
// AgentOption mutates an Agent at construction time. The variadic option
// set lets New stay backward compatible — an Agent built with no options
// behaves exactly as before.
type AgentOption func(*Agent)
// WithCompactor installs a rolling-summary compactor on the agent. A nil
// compactor (or one with a nil summarizer) leaves the agent's Run identical
// to its un-compacted behaviour.
func WithCompactor(c *RollingCompactor) AgentOption {
return func(a *Agent) { a.compactor = c }
}
// WithSyncCompaction is a test/throttling seam that forces synchronous
// (inline) summarization. Used when the run deadline is too short for the
// async path. It is exposed as an option so callers that already know their
// per-call deadline is tight can opt in without reflection.
func WithSyncCompaction() AgentOption {
return func(a *Agent) {
if a.compactor != nil {
a.compactor.sync = true
}
}
}
// enabled reports whether the compactor can actually compact (it has a
// summarizer). A nil receiver or nil summarizer disables compaction.
func (c *RollingCompactor) enabled() bool {
return c != nil && c.summarizer != nil
}
// wait blocks until any spawned background summarizer goroutine has
// returned. Run calls it (after cancelling the summarizer's ctx) so no
// goroutine — and no usage attribution — leaks past the Run that started it.
func (c *RollingCompactor) wait() {
if c == nil {
return
}
c.wg.Wait()
}
// partitionZones splits a running conversation into frozen / compress /
// active zones. The system prompt (conv[0]) and the first user task
// (conv[1]) are pinned out of every zone and are NOT returned here — the
// caller re-attaches them. Already-summarized frozen notes (recognised by
// summarySentinel) are carried into frozen and never re-compressed. The
// most recent activeRounds rounds (each round = an assistant message plus
// its following tool observation, i.e. two messages) stay in active,
// verbatim; everything between frozen and active is compress.
func partitionZones(conv []llm.Message, activeRounds int) (frozen, compress, active []llm.Message) {
if len(conv) <= 2 {
return nil, nil, nil
}
body := conv[2:] // drop pinned system + first user task
// Leading frozen summaries stay frozen.
i := 0
for i < len(body) && isFrozenSummary(body[i]) {
frozen = append(frozen, body[i])
i++
}
rest := body[i:]
// The tail activeRounds rounds (2 messages each) are kept verbatim.
activeMsgs := activeRounds * 2
if activeMsgs < 0 {
activeMsgs = 0
}
if activeMsgs >= len(rest) {
// Everything that isn't frozen still fits in active.
active = append(active, rest...)
return frozen, nil, active
}
split := len(rest) - activeMsgs
compress = append(compress, rest[:split]...)
active = append(active, rest[split:]...)
return frozen, compress, active
}
// isFrozenSummary reports whether m is a synthetic rolling-summary note.
func isFrozenSummary(m llm.Message) bool {
return m.Role == llm.RoleAssistant && strings.HasPrefix(m.Content, summarySentinel)
}
// countRounds returns how many (assistant, tool) round pairs the zone holds.
// A trailing lone assistant message counts as a partial round.
func countRounds(zone []llm.Message) int {
return (len(zone) + 1) / 2
}
// estimateConvTokens sizes the conversation. When the most recent provider
// usage reports a nonzero prompt-token count it is preferred (it is the
// authoritative count of what the provider actually billed for the whole
// conversation prefix); otherwise the size is summed from the message text
// via tokens.Count.
func estimateConvTokens(conv []llm.Message, lastUsage llm.TokenUsage) int {
if lastUsage.InputTokens > 0 {
return lastUsage.InputTokens
}
total := 0
for _, m := range conv {
total += tokens.Count(m.Content)
}
return total
}
// maybeCompact folds the compress zone into a single frozen summary when the
// conversation has crossed the high-water mark and the compress zone holds at
// least two rounds. It returns the (possibly) compacted conversation.
//
// Cancellation / cost-attribution contract (decided in-spec):
// - summCtx is derived from runCtx via context.WithCancel; the caller (Run)
// cancels it on return (defer cancel()). So an in-flight summarizer call
// cannot outlive the request.
// - A summary that arrives after its context is done is DROPPED — the splice
// site checks summCtx.Err()==nil before applying.
// - The summarizer's own token usage is attributed: it is folded into the
// passed *usage under mu, so background spend is never unaccounted.
// - When sync==true (deadline too short for the async path) the summarize
// runs inline; otherwise a single background goroutine is spawned and the
// result is spliced on the next turn that finds it ready.
func (c *RollingCompactor) maybeCompact(runCtx context.Context, conv []llm.Message, sizeTokens int, usage *llm.TokenUsage, mu *sync.Mutex) (compacted []llm.Message, didCompact bool) {
if !c.enabled() {
return conv, false
}
// First, splice in any async summary that has completed since the last
// turn (and whose context was not cancelled before completion).
if spliced, ok := c.applyReadySummary(conv); ok {
return spliced, true
}
if sizeTokens < c.CompressTriggerTokens {
return conv, false
}
frozen, compress, active := partitionZones(conv, c.ActiveRounds)
if countRounds(compress) < 2 {
return conv, false // nothing eligible to fold yet
}
// Synchronous path: summarize inline and splice now.
if c.sync {
summCtx, cancel := context.WithCancel(runCtx)
defer cancel()
summary, u, err := c.summarizeZone(summCtx, compress)
// Attribute the summarizer's spend regardless of splice outcome.
mu.Lock()
usage.Add(u)
mu.Unlock()
if err != nil || summCtx.Err() != nil || strings.TrimSpace(summary) == "" {
return conv, false
}
return c.spliceSummary(conv, frozen, active, summary), true
}
// Async path: spawn a single background summarizer. The next turn that
// finds the result ready splices it (above).
c.spawnSummarize(runCtx, compress, usage, mu)
return conv, false
}
// spawnSummarize launches at most one background summarizer goroutine on a
// context derived from runCtx. Run cancels that ctx on return; a summary
// produced after cancellation is dropped (its usage is still attributed).
func (c *RollingCompactor) spawnSummarize(runCtx context.Context, compress []llm.Message, usage *llm.TokenUsage, mu *sync.Mutex) {
c.pendingMu.Lock()
if c.inflight || c.ready {
c.pendingMu.Unlock()
return // a summarizer is already running or a result is pending
}
c.inflight = true
c.pendingMu.Unlock()
zone := append([]llm.Message(nil), compress...) // snapshot — caller may mutate conv
c.wg.Add(1)
go func() {
defer c.wg.Done()
summCtx, cancel := context.WithCancel(runCtx)
defer cancel()
summary, u, err := c.summarizeZone(summCtx, zone)
// Attribute spend even when the result is dropped — background
// tokens are never unaccounted.
mu.Lock()
usage.Add(u)
mu.Unlock()
c.pendingMu.Lock()
c.inflight = false
// Drop a summary whose context was cancelled before it completed,
// or an empty/failed one. A dropped summary leaves the conversation
// unchanged on the next turn.
if err == nil && summCtx.Err() == nil && strings.TrimSpace(summary) != "" {
c.summary = summary
c.ready = true
}
c.pendingMu.Unlock()
}()
}
// applyReadySummary splices a completed (non-cancelled) async summary into
// the conversation, if one is waiting. It re-partitions the current
// conversation so the splice reflects whatever rounds have been appended
// since the summarizer was launched.
func (c *RollingCompactor) applyReadySummary(conv []llm.Message) ([]llm.Message, bool) {
c.pendingMu.Lock()
if !c.ready {
c.pendingMu.Unlock()
return conv, false
}
summary := c.summary
c.ready = false
c.summary = ""
c.pendingMu.Unlock()
frozen, compress, active := partitionZones(conv, c.ActiveRounds)
if countRounds(compress) < 2 {
// The compress zone shrank below the fold threshold since the
// summarizer launched (unlikely — the conversation only grows).
// Drop the stale summary rather than fold a single round.
return conv, false
}
return c.spliceSummary(conv, frozen, active, summary), true
}
// spliceSummary rebuilds the conversation as: pinned (system + first task) +
// existing frozen notes + one new frozen summary + active rounds. The
// compress zone is dropped — it is now represented by the summary.
func (c *RollingCompactor) spliceSummary(conv, frozen, active []llm.Message, summary string) []llm.Message {
out := make([]llm.Message, 0, 2+len(frozen)+1+len(active))
out = append(out, conv[0], conv[1]) // pinned system + first user task
out = append(out, frozen...)
out = append(out, llm.Message{
Role: llm.RoleAssistant,
Content: summarySentinel + summary,
})
out = append(out, active...)
return out
}
// summarizeZone asks the summarizer provider to compress the compress-zone
// rounds into a concise note targeting CompressTargetTokens. It returns the
// note text and the provider's own token usage (for cost attribution).
func (c *RollingCompactor) summarizeZone(ctx context.Context, rounds []llm.Message) (string, llm.TokenUsage, error) {
var b strings.Builder
for _, m := range rounds {
switch m.Role {
case llm.RoleAssistant:
b.WriteString("ASSISTANT (tool call): ")
case llm.RoleTool:
fmt.Fprintf(&b, "TOOL RESULT (%s): ", m.ToolName)
case llm.RoleUser:
b.WriteString("USER: ")
default:
b.WriteString(string(m.Role))
b.WriteString(": ")
}
b.WriteString(m.Content)
b.WriteString("\n")
}
prompt := fmt.Sprintf(
"Summarize the following earlier steps of an agent tool-using session into a"+
" compact note of at most %d tokens. Preserve the concrete facts, symbol ids,"+
" file paths, and intermediate conclusions a later turn would need; drop"+
" redundant tool chatter. Output only the summary text.\n\n%s",
c.CompressTargetTokens, b.String(),
)
resp, err := c.summarizer.Complete(ctx, llm.CompletionRequest{
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
MaxTokens: c.CompressTargetTokens,
Shape: llm.ShapeFreeform,
})
if err != nil {
return "", resp.Usage, err
}
return strings.TrimSpace(resp.Text), resp.Usage, nil
}