| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | ) |
| 9 | |
| 10 | // Truncation is the lossy last rung of overflow recovery, taken only when no |
| 11 | // summary can form: tool results outside the protected tail are elided |
| 12 | // oldest-first, then whole replay units are dropped, until the view fits under |
| 13 | // the target. It is a projection; canonical storage keeps every byte. |
| 14 | const ( |
| 15 | maintenanceActionTruncate = "truncate" |
| 16 | elidedToolResultPrefix = "[tool result elided to fit the context window" |
| 17 | truncatedHistoryMarker = "[earlier conversation truncated to fit the context window: %d messages removed]" |
| 18 | // truncateProtectShare bounds the verbatim tail to this fraction of the |
| 19 | // target so a rescue can always reclaim enough. |
| 20 | truncateProtectShare = 4 |
| 21 | ) |
| 22 | |
| 23 | func (a *Agent) truncateToProjectionLocked(trigger string, target int) (bool, error) { |
| 24 | canonical, transcriptVersion := a.sess.conversation.snapshotMessagesVersion() |
| 25 | a.sess.compactionMu.Lock() |
| 26 | stateSnapshot := a.sess.compactionState |
| 27 | a.sess.compactionMu.Unlock() |
| 28 | visible, _ := a.visibleInputForFold(stateSnapshot, canonical, transcriptVersion) |
| 29 | projected, affected := a.truncateView(visible, target) |
| 30 | if affected == 0 { |
| 31 | return false, nil |
| 32 | } |
| 33 | return a.installMaintenanceProjection(maintenanceInstall{ |
| 34 | trigger: trigger, action: maintenanceActionTruncate, state: stateSnapshot, |
| 35 | canonical: canonical, transcriptVersion: transcriptVersion, |
| 36 | visible: visible, projected: projected, affected: affected, |
| 37 | }) |
| 38 | } |
| 39 | |
| 40 | // truncateView returns the truncated copy of visible and how many messages it |
| 41 | // changed; zero means the view already fits or nothing could be cut. |
| 42 | func (a *Agent) truncateView(visible []provider.Message, target int) ([]provider.Message, int) { |
| 43 | total := a.estimatedVisibleRequestTokens(visible) |
| 44 | if target <= 0 || total < target || len(visible) == 0 { |
| 45 | return nil, 0 |
| 46 | } |
| 47 | head := a.pinnedPrefixLen(visible) |
| 48 | budget := max(1, min(a.recentTailBudget(), target/truncateProtectShare)) |
| 49 | protect := tailStart(visible, head, budget, a.tokPerChar(), minRecentKeep) |
| 50 | projected := append([]provider.Message(nil), visible...) |
| 51 | remaining, affected := total, 0 |
| 52 | for i := head; i < protect && remaining >= target; i++ { |
| 53 | elided, ok := elideToolResult(projected[i]) |
| 54 | if !ok { |
| 55 | continue |
| 56 | } |
| 57 | remaining -= a.messageTokens(projected[i]) - a.messageTokens(elided) |
| 58 | projected[i] = elided |
| 59 | affected++ |
| 60 | } |
| 61 | if remaining >= target { |
| 62 | var dropped int |
| 63 | projected, dropped = a.dropOldestUnits(projected, head, protect, target) |
| 64 | affected += dropped |
| 65 | } |
| 66 | if affected == 0 || a.estimatedVisibleRequestTokens(projected) >= total { |
| 67 | return nil, 0 |
| 68 | } |
| 69 | return projected, affected |
| 70 | } |
| 71 | |
| 72 | func (a *Agent) messageTokens(m provider.Message) int { |
| 73 | return a.estimatedPromptTokens([]provider.Message{m}) |
| 74 | } |
| 75 | |
| 76 | func elideToolResult(m provider.Message) (provider.Message, bool) { |
| 77 | if m.Role != provider.RoleTool || m.LocalOnly || m.Content == "" || strings.HasPrefix(m.Content, elidedToolResultPrefix) { |
| 78 | return m, false |
| 79 | } |
| 80 | out := m |
| 81 | out.Content = fmt.Sprintf("%s: %d bytes]", elidedToolResultPrefix, len(m.Content)) |
| 82 | out.RawContent = "" |
| 83 | out.ProviderContent = "" |
| 84 | out.Images = nil |
| 85 | out.ImageInputs = nil |
| 86 | return out, true |
| 87 | } |
| 88 | |
| 89 | // dropOldestUnits removes whole replay units from the oldest end of the |
| 90 | // foldable region until the estimate fits. The latest session context, |
| 91 | // compaction digests, and pinned revisions survive behind one marker. |
| 92 | func (a *Agent) dropOldestUnits(msgs []provider.Message, head, protect, target int) ([]provider.Message, int) { |
| 93 | if protect <= head { |
| 94 | return msgs, 0 |
| 95 | } |
| 96 | remaining := a.estimatedVisibleRequestTokens(msgs) |
| 97 | latestContext := latestSessionContextIndex(msgs) |
| 98 | var kept []provider.Message |
| 99 | dropped, end := 0, head |
| 100 | for _, u := range extractMessageUnits(msgs[head:protect]) { |
| 101 | if remaining < target { |
| 102 | break |
| 103 | } |
| 104 | for i := head + u.lo; i < head+u.hi; i++ { |
| 105 | if i == latestContext || isCompactionSummary(msgs[i]) || IsPinnedContextRevision(msgs[i]) { |
| 106 | kept = append(kept, msgs[i]) |
| 107 | continue |
| 108 | } |
| 109 | remaining -= a.messageTokens(msgs[i]) |
| 110 | dropped++ |
| 111 | } |
| 112 | end = head + u.hi |
| 113 | } |
| 114 | if dropped == 0 { |
| 115 | return msgs, 0 |
| 116 | } |
| 117 | out := make([]provider.Message, 0, len(msgs)-dropped+1) |
| 118 | out = append(out, msgs[:head]...) |
| 119 | out = append(out, HostGeneratedUserMessage(fmt.Sprintf(truncatedHistoryMarker, dropped))) |
| 120 | out = append(out, kept...) |
| 121 | out = append(out, msgs[end:]...) |
| 122 | return out, dropped |
| 123 | } |
| 124 |