| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log/slog" |
| 8 | "sync/atomic" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | // compactionProgress is how compaction is faring in this session: whether a |
| 14 | // fold stopped reducing, how many ran back to back, and which retries already |
| 15 | // ran in the active turn. The fields are cleared together on lineage resets. |
| 16 | type compactionProgress struct { |
| 17 | stuck bool // a fold landed above the trigger, so the same-view pressure retry is pointless |
| 18 | stuckInputHash string // provider-visible view covered by stuck; changed input may retry |
| 19 | consecutive int // back-to-back folds since one last helped |
| 20 | // failedTurn backs off changed-view retries within one active tool loop. |
| 21 | // A later user turn may retry, while hard-ceiling recovery bypasses it. |
| 22 | failedTurn atomic.Int64 |
| 23 | // lastTurn stops the post-turn observer and the pre-send preflight from |
| 24 | // paying for two summaries during one active tool loop. |
| 25 | lastTurn atomic.Int64 |
| 26 | } |
| 27 | |
| 28 | // ContextManager is the sole owner of provider-visible context maintenance. |
| 29 | // Canonical session messages are immutable inputs; Prepare evolves only the |
| 30 | // durable projection and returns the exact visible view for one sampling round. |
| 31 | type ContextManager struct { |
| 32 | agent *Agent |
| 33 | } |
| 34 | |
| 35 | // ContextPreparePolicy describes one maintenance transaction. |
| 36 | type ContextPreparePolicy struct { |
| 37 | Trigger string |
| 38 | Instructions string |
| 39 | Force bool |
| 40 | // ObservedInputTokens is used by compatibility harnesses that invoke the |
| 41 | // old post-turn shim directly. Production Prepare estimates the current view |
| 42 | // from its calibrated final request shape. |
| 43 | ObservedInputTokens int |
| 44 | // AllowChunkedFallback enables fragment/tree-reduce recovery after a single |
| 45 | // summary fails. Ordinary pressure/overflow leave this false. |
| 46 | AllowChunkedFallback bool |
| 47 | } |
| 48 | |
| 49 | // PreparedContext is the frozen result of a successful Prepare transaction. |
| 50 | type PreparedContext struct { |
| 51 | Messages []provider.Message |
| 52 | InputTokens int |
| 53 | ProjectionVersion uint64 |
| 54 | } |
| 55 | |
| 56 | func (a *Agent) contextManager() ContextManager { return ContextManager{agent: a} } |
| 57 | |
| 58 | // PrepareContext is the public automatic-maintenance entry used by smoke tools |
| 59 | // and controllers that need a one-shot Prepare without sampling. |
| 60 | func (a *Agent) PrepareContext(ctx context.Context) error { |
| 61 | _, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure}) |
| 62 | return err |
| 63 | } |
| 64 | |
| 65 | // ObserveUsage is retained as a compatibility hook. Usage observations never |
| 66 | // mutate the provider-visible checkpoint. |
| 67 | func (m ContextManager) ObserveUsage(u *provider.Usage) { |
| 68 | _ = u |
| 69 | } |
| 70 | |
| 71 | // Prepare is the sole automatic maintenance entry. Below compact_ratio it does |
| 72 | // nothing. At or above the trigger it runs one single-flight prune/summary |
| 73 | // transaction, with at most two successful summary attempts under pressure. |
| 74 | func (m ContextManager) Prepare(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) { |
| 75 | // Legacy desktop callers can compact before their runtime context is installed. |
| 76 | if ctx == nil { |
| 77 | ctx = context.Background() |
| 78 | } |
| 79 | if err := ctx.Err(); err != nil { |
| 80 | return PreparedContext{}, err |
| 81 | } |
| 82 | if policy.Trigger == "" { |
| 83 | policy.Trigger = CompactionTriggerPressure |
| 84 | } |
| 85 | if m.agent == nil { |
| 86 | return PreparedContext{}, nil |
| 87 | } |
| 88 | m.agent.sess.compactionRunMu.Lock() |
| 89 | defer m.agent.sess.compactionRunMu.Unlock() |
| 90 | // Cancellation may have arrived while another maintenance transaction held the lock. |
| 91 | // Reject it before any fast path or projection maintenance can run. |
| 92 | if err := ctx.Err(); err != nil { |
| 93 | return PreparedContext{}, err |
| 94 | } |
| 95 | return m.prepareOnce(ctx, policy) |
| 96 | } |
| 97 | |
| 98 | func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) { |
| 99 | a := m.agent |
| 100 | if a == nil || a.sess.conversation == nil { |
| 101 | return PreparedContext{}, nil |
| 102 | } |
| 103 | visible := a.modelVisibleMessages() |
| 104 | // Threshold uses the stable pre-interceptor request shape (messages + tools |
| 105 | // + role projection). Extension interceptors run only on the real sampling |
| 106 | // request so side-effecting plugins are not double-invoked; if they expand |
| 107 | // the prompt past the hard ceiling, overflow recovery still fires. |
| 108 | est := a.estimatedVisibleRequestTokens(visible) |
| 109 | viewEst := est |
| 110 | prepared := PreparedContext{ |
| 111 | Messages: append([]provider.Message(nil), visible...), |
| 112 | InputTokens: est, |
| 113 | ProjectionVersion: a.currentProjectionVersion(), |
| 114 | } |
| 115 | if a.contextWindow <= 0 || len(visible) == 0 { |
| 116 | return prepared, nil |
| 117 | } |
| 118 | fold := a.compactTrigger() |
| 119 | hard := a.hardInputCeiling() |
| 120 | if policy.ObservedInputTokens > 0 { |
| 121 | est = policy.ObservedInputTokens |
| 122 | prepared.InputTokens = est |
| 123 | } |
| 124 | inputHash := a.contextMaintenanceInputHash(visible) |
| 125 | // Receipts back off sub-critical retries only. A failed summary never |
| 126 | // fabricates a digest; at the ceiling the lossy truncation rescue is the |
| 127 | // last resort, so the turn still leaves with a view the provider accepts. |
| 128 | if blocked, _ := a.contextMaintenanceBlocked(inputHash, viewEst); blocked && policy.Trigger != CompactionTriggerManual && |
| 129 | policy.Trigger != CompactionTriggerOverflow && est < hard { |
| 130 | return prepared, nil |
| 131 | } |
| 132 | if est < fold { |
| 133 | a.resetCompactionProgress() |
| 134 | } |
| 135 | if a.sess.compaction.stuck && a.sess.compaction.stuckInputHash != inputHash { |
| 136 | // The previous projection could not reclaim enough from its exact view, |
| 137 | // but newly appended messages create a new fold boundary and may retry. |
| 138 | a.sess.compaction.stuck = false |
| 139 | a.sess.compaction.stuckInputHash = "" |
| 140 | a.sess.compaction.consecutive = 0 |
| 141 | } |
| 142 | if a.sess.compaction.stuck && policy.Trigger == CompactionTriggerPressure && est < hard { |
| 143 | return prepared, nil |
| 144 | } |
| 145 | // One user trigger. Overflow is a one-shot physical recovery path only. |
| 146 | forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= hard |
| 147 | if est < fold && !forceFold { |
| 148 | return prepared, nil |
| 149 | } |
| 150 | |
| 151 | // A manual compact over the hard ceiling is a rescue, not a convenience: |
| 152 | // prune first so the never-folded recent tail can shrink too. |
| 153 | if shouldPruneBeforeFold(policy.Trigger, est >= hard) { |
| 154 | applied, err := a.pruneToolResultsToProjectionLocked(policy.Trigger) |
| 155 | if err != nil { |
| 156 | return PreparedContext{}, err |
| 157 | } |
| 158 | if applied { |
| 159 | prepared = m.currentPrepared() |
| 160 | est = prepared.InputTokens |
| 161 | inputHash = a.contextMaintenanceInputHash(prepared.Messages) |
| 162 | if (policy.Trigger == CompactionTriggerPressure && est < fold) || |
| 163 | (policy.Trigger == CompactionTriggerOverflow && est < hard) { |
| 164 | return prepared, nil |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | return m.foldContext(ctx, prepared, policy, inputHash, est, fold, hard, forceFold) |
| 170 | } |
| 171 | |
| 172 | func shouldPruneBeforeFold(trigger string, overHardCeiling bool) bool { |
| 173 | switch trigger { |
| 174 | case CompactionTriggerPressure, CompactionTriggerOverflow: |
| 175 | return true |
| 176 | case CompactionTriggerManual: |
| 177 | return overHardCeiling |
| 178 | default: |
| 179 | return false |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // manualRecoverySummaries bounds the rescue loop for a manual compact that |
| 184 | // starts at or above the hard input ceiling. Each batch folds the largest |
| 185 | // admissible prefix, so a handful of batches recovers even a view several |
| 186 | // times the window while capping summarizer spend on pathological input. |
| 187 | const manualRecoverySummaries = 4 |
| 188 | |
| 189 | func maxSummariesFor(policy ContextPreparePolicy, overCeiling bool) int { |
| 190 | switch { |
| 191 | case policy.Trigger == CompactionTriggerManual && overCeiling: |
| 192 | return manualRecoverySummaries |
| 193 | case policy.Trigger == CompactionTriggerPressure: |
| 194 | return 2 |
| 195 | default: |
| 196 | return 1 |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, hard int, forceFold bool) (PreparedContext, error) { |
| 201 | a := m.agent |
| 202 | maxSummaries := maxSummariesFor(policy, est >= hard) |
| 203 | ladder := newSummaryLadder(maxSummaries) |
| 204 | result := prepared |
| 205 | for ladder.next() { |
| 206 | mustFree := policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard |
| 207 | outcome, err := a.compactToProjectionLocked(ctx, policy.Trigger, policy.Instructions, |
| 208 | ladder.request(forceFold, mustFree, policy.AllowChunkedFallback)) |
| 209 | if err != nil { |
| 210 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { |
| 211 | return PreparedContext{}, err |
| 212 | } |
| 213 | if ladder.absorbOverflow(err) { |
| 214 | continue |
| 215 | } |
| 216 | return m.summaryFailed(policy, inputHash, hard, err) |
| 217 | } |
| 218 | if outcome == CompactionNoop { |
| 219 | return m.summaryNoop(policy, inputHash, hard) |
| 220 | } |
| 221 | |
| 222 | result = m.currentPrepared() |
| 223 | if foldLanded(policy, result.InputTokens, fold, hard) { |
| 224 | a.resetCompactionProgress() |
| 225 | return result, nil |
| 226 | } |
| 227 | forceFold = false |
| 228 | inputHash = a.contextMaintenanceInputHash(result.Messages) |
| 229 | } |
| 230 | |
| 231 | reason := fmt.Sprintf("summary result remains above fold trigger after %d attempts (%d >= %d)", maxSummaries, result.InputTokens, fold) |
| 232 | blockedInputHash := a.contextMaintenanceInputHash(result.Messages) |
| 233 | a.recordContextMaintenanceBlocked(blockedInputHash, policy.Trigger, "summary", reason) |
| 234 | a.sess.compaction.stuck = true |
| 235 | a.sess.compaction.stuckInputHash = blockedInputHash |
| 236 | a.sess.compaction.consecutive += maxSummaries |
| 237 | if policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard { |
| 238 | return m.rescueByTruncation(policy, hard, errors.New(reason)) |
| 239 | } |
| 240 | slog.Info("agent: context maintenance paused below hard ceiling", "reason", reason) |
| 241 | return result, nil |
| 242 | } |
| 243 | |
| 244 | func foldLanded(policy ContextPreparePolicy, tokens, fold, hard int) bool { |
| 245 | switch policy.Trigger { |
| 246 | case CompactionTriggerManual, CompactionTriggerOverflow: |
| 247 | return tokens < hard || tokens < fold |
| 248 | default: |
| 249 | return tokens < fold |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func (m ContextManager) summaryFailed(policy ContextPreparePolicy, inputHash string, hard int, err error) (PreparedContext, error) { |
| 254 | a := m.agent |
| 255 | if errors.Is(err, errCompressStaleContext) && policy.Trigger != CompactionTriggerManual { |
| 256 | reason := "context changed during summary; automatic retry blocked for this generation" |
| 257 | a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason) |
| 258 | return m.rescueOrFail(policy, hard, errors.New(reason)) |
| 259 | } |
| 260 | status := "failed" |
| 261 | if errors.Is(err, errSummaryOutputTruncated) || errors.Is(err, errCheckpointRejected) { |
| 262 | status = "blocked" |
| 263 | } |
| 264 | a.recordContextMaintenanceOutcome(inputHash, policy.Trigger, "summary", status, fmt.Sprintf("context summary failed: %v", err)) |
| 265 | return m.rescueOrFail(policy, hard, err) |
| 266 | } |
| 267 | |
| 268 | func (m ContextManager) summaryNoop(policy ContextPreparePolicy, inputHash string, hard int) (PreparedContext, error) { |
| 269 | reason := "context is above the maintenance threshold but no foldable region remains" |
| 270 | m.agent.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason) |
| 271 | latest := m.currentPrepared() |
| 272 | switch { |
| 273 | case policy.Trigger == CompactionTriggerOverflow || latest.InputTokens >= hard: |
| 274 | return m.rescueByTruncation(policy, hard, errors.New(reason)) |
| 275 | case policy.Force: |
| 276 | return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) |
| 277 | default: |
| 278 | return latest, nil |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // rescueOrFail decides what a failed summary means: below the ceiling |
| 283 | // automatic maintenance waits for the next view and a manual compact reports |
| 284 | // the error; at or above the ceiling only the lossy truncation rescue is left. |
| 285 | func (m ContextManager) rescueOrFail(policy ContextPreparePolicy, hard int, cause error) (PreparedContext, error) { |
| 286 | latest := m.currentPrepared() |
| 287 | if policy.Trigger != CompactionTriggerOverflow && latest.InputTokens < hard { |
| 288 | if policy.Trigger == CompactionTriggerManual { |
| 289 | return PreparedContext{}, cause |
| 290 | } |
| 291 | return latest, nil |
| 292 | } |
| 293 | return m.rescueByTruncation(policy, hard, cause) |
| 294 | } |
| 295 | |
| 296 | // rescueByTruncation installs the lossy truncation projection aimed at the |
| 297 | // fold trigger so the turn leaves the ceiling with headroom. cause is the |
| 298 | // summary failure it stands in for and stays in the error when even that fails. |
| 299 | func (m ContextManager) rescueByTruncation(policy ContextPreparePolicy, hard int, cause error) (PreparedContext, error) { |
| 300 | a := m.agent |
| 301 | applied, err := a.truncateToProjectionLocked(policy.Trigger, a.compactTrigger()) |
| 302 | if err != nil { |
| 303 | return PreparedContext{}, fmt.Errorf("%w: %w (truncation: %w)", ErrCompactionRequired, cause, err) |
| 304 | } |
| 305 | if !applied { |
| 306 | return PreparedContext{}, fmt.Errorf("%w: %w", ErrCompactionRequired, cause) |
| 307 | } |
| 308 | latest := m.currentPrepared() |
| 309 | if latest.InputTokens >= hard { |
| 310 | return PreparedContext{}, fmt.Errorf("%w: truncated view still %d >= %d", ErrCompactionRequired, latest.InputTokens, hard) |
| 311 | } |
| 312 | a.resetCompactionProgress() |
| 313 | return latest, nil |
| 314 | } |
| 315 | |
| 316 | func (a *Agent) resetCompactionProgress() { |
| 317 | a.sess.compaction.stuck = false |
| 318 | a.sess.compaction.stuckInputHash = "" |
| 319 | a.sess.compaction.consecutive = 0 |
| 320 | a.sess.compaction.failedTurn.Store(0) |
| 321 | } |
| 322 | |
| 323 | func (m ContextManager) currentPrepared() PreparedContext { |
| 324 | if m.agent == nil { |
| 325 | return PreparedContext{} |
| 326 | } |
| 327 | visible := m.agent.modelVisibleMessages() |
| 328 | return PreparedContext{ |
| 329 | Messages: append([]provider.Message(nil), visible...), |
| 330 | InputTokens: m.agent.estimatedVisibleRequestTokens(visible), |
| 331 | ProjectionVersion: m.agent.currentProjectionVersion(), |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // estimatedVisibleRequestTokens sizes the pre-interceptor sampling shape: |
| 336 | // ModelMessages + role projection + tool schemas. Extension interceptors are |
| 337 | // intentionally omitted here (see prepareOnce) to avoid double side effects. |
| 338 | func (a *Agent) estimatedVisibleRequestTokens(visible []provider.Message) int { |
| 339 | if a == nil { |
| 340 | return 0 |
| 341 | } |
| 342 | msgs := a.normalizeModelRequestMessages(visible) |
| 343 | tools := a.providerToolSchemas() |
| 344 | return a.estimatedRequestTokens(provider.Request{ |
| 345 | Messages: msgs, |
| 346 | Tools: tools, |
| 347 | MaxTokens: a.maxOutputTokens, |
| 348 | Temperature: provider.OptionalTemperature(a.temperature), |
| 349 | }) |
| 350 | } |
| 351 |