| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/attachment" |
| 13 | "reasonix/internal/fileutil" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/store" |
| 16 | ) |
| 17 | |
| 18 | // Context-projection schema versions. Readers accept any known version; |
| 19 | // writers always emit the current schema. |
| 20 | const ( |
| 21 | compactionStateSchemaV1 = 1 |
| 22 | compactionStateSchemaV2 = 2 |
| 23 | compactionStateSchemaV3 = 3 |
| 24 | compactionStateSchemaV4 = 4 |
| 25 | compactionStateSchemaCurrent = compactionStateSchemaV4 |
| 26 | ) |
| 27 | |
| 28 | // Cache state labels for resume/preflight telemetry. They never enter the |
| 29 | // provider-visible prompt. |
| 30 | const ( |
| 31 | CacheStateWarm = "warm" |
| 32 | CacheStateCold = "cold" |
| 33 | CacheStateUnknown = "unknown" |
| 34 | ) |
| 35 | |
| 36 | // Compaction trigger labels. |
| 37 | const ( |
| 38 | CompactionTriggerPressure = "pressure" |
| 39 | CompactionTriggerManual = "manual" |
| 40 | CompactionTriggerOverflow = "overflow" |
| 41 | CompactionTriggerSnip = "snip" |
| 42 | CompactionTriggerTool = "tool" |
| 43 | ) |
| 44 | |
| 45 | // Compaction mode labels. |
| 46 | const ( |
| 47 | CompactionModeNative = "native" |
| 48 | CompactionModeSummarized = "summarized" |
| 49 | CompactionModeChunked = "chunked" |
| 50 | CompactionModeDegraded = "degraded" |
| 51 | CompactionModeSnip = "snip" |
| 52 | ) |
| 53 | |
| 54 | const ( |
| 55 | SummaryInputCachePrefix = "cache_prefix" |
| 56 | SummaryInputExtensionRewritten = "extension_rewritten" |
| 57 | SummaryInputNonPrefix = "non_prefix" |
| 58 | SummaryInputChunked = "chunked" |
| 59 | SummaryInputSlim = "slim" |
| 60 | ) |
| 61 | |
| 62 | // ContextProjection is the model-visible view of a session. The canonical |
| 63 | // transcript in Session.Messages is never replaced by this structure. |
| 64 | type ContextProjection struct { |
| 65 | Messages []provider.Message `json:"messages"` |
| 66 | TranscriptVersion uint64 `json:"transcript_version"` |
| 67 | ProjectionVersion uint64 `json:"projection_version"` |
| 68 | // CoveredCount is the canonical prefix represented by the frozen projection |
| 69 | // body. Model-visible context is projection.Messages + canonical[CoveredCount:]. |
| 70 | CoveredCount int `json:"covered_count"` |
| 71 | // CoveredPrefixHash fingerprints provider-visible canonical[:CoveredCount] |
| 72 | // so append-only growth can be distinguished from prefix edits/rewrites. |
| 73 | CoveredPrefixHash string `json:"covered_prefix_hash,omitempty"` |
| 74 | // PinnedContextHash authenticates host-origin provenance inside the covered |
| 75 | // canonical prefix. Provider bytes omit Origin, so CoveredPrefixHash alone |
| 76 | // cannot detect provenance edits that change checkpoint reconstruction. |
| 77 | PinnedContextHash string `json:"pinned_context_hash,omitempty"` |
| 78 | SummaryHash string `json:"summary_hash,omitempty"` |
| 79 | SourceTokens int `json:"source_tokens,omitempty"` |
| 80 | ProjectionTokens int `json:"projection_tokens,omitempty"` |
| 81 | // ViewInputHash/ViewOutputHash make free maintenance idempotent across |
| 82 | // retries and resume. They fingerprint the visible view, not canonical |
| 83 | // storage, so a projection can evolve without rewriting the transcript. |
| 84 | ViewInputHash string `json:"view_input_hash,omitempty"` |
| 85 | ViewOutputHash string `json:"view_output_hash,omitempty"` |
| 86 | CreatedAt time.Time `json:"created_at"` |
| 87 | } |
| 88 | |
| 89 | // ContextMaintenanceReceipt is the durable, provider-neutral outcome of one |
| 90 | // context maintenance transaction. Transcript content is intentionally not |
| 91 | // included; hashes and counts are sufficient for dedupe and diagnostics. |
| 92 | type ContextMaintenanceReceipt struct { |
| 93 | OperationID string `json:"operation_id,omitempty"` |
| 94 | Status string `json:"status,omitempty"` // planned|applied|noop|blocked|failed |
| 95 | Action string `json:"action,omitempty"` // snip|prune|summary|truncate|native_tool_clear|noop |
| 96 | Trigger string `json:"trigger,omitempty"` |
| 97 | SourceProjection uint64 `json:"source_projection,omitempty"` |
| 98 | ProjectionVersion uint64 `json:"projection_version,omitempty"` |
| 99 | CoveredCount int `json:"covered_count,omitempty"` |
| 100 | CoveredPrefixHash string `json:"covered_prefix_hash,omitempty"` |
| 101 | InputHash string `json:"input_hash,omitempty"` |
| 102 | OutputHash string `json:"output_hash,omitempty"` |
| 103 | InputTokens int `json:"input_tokens,omitempty"` |
| 104 | ResultTokens int `json:"result_tokens,omitempty"` |
| 105 | SavedTokens int `json:"saved_tokens,omitempty"` |
| 106 | AffectedToolResults int `json:"affected_tool_results,omitempty"` |
| 107 | SummaryHash string `json:"summary_hash,omitempty"` |
| 108 | Archive string `json:"archive,omitempty"` |
| 109 | CacheBreak bool `json:"cache_break,omitempty"` |
| 110 | Reason string `json:"reason,omitempty"` |
| 111 | BlockedInputHash string `json:"blocked_input_hash,omitempty"` |
| 112 | CreatedAt time.Time `json:"created_at,omitempty"` |
| 113 | } |
| 114 | |
| 115 | // CompactionOutcome reports whether compactToProjection installed a projection. |
| 116 | type CompactionOutcome int |
| 117 | |
| 118 | const ( |
| 119 | // CompactionInstalled means a new (or replacement) projection was saved. |
| 120 | CompactionInstalled CompactionOutcome = iota |
| 121 | // CompactionNoop means no fold region / economics skip / empty fold after hooks. |
| 122 | CompactionNoop |
| 123 | ) |
| 124 | |
| 125 | // CompactionState is the session context sidecar payload. |
| 126 | type CompactionState struct { |
| 127 | SchemaVersion int `json:"schema_version"` |
| 128 | TranscriptVersion uint64 `json:"transcript_version"` |
| 129 | Projection ContextProjection `json:"projection"` |
| 130 | PromptCacheKey string `json:"prompt_cache_key,omitempty"` |
| 131 | LastCacheState string `json:"last_cache_state,omitempty"` |
| 132 | LastTrigger string `json:"last_trigger,omitempty"` |
| 133 | LastMode string `json:"last_mode,omitempty"` |
| 134 | LastSourceTokens int `json:"last_source_tokens,omitempty"` |
| 135 | LastResultTokens int `json:"last_result_tokens,omitempty"` |
| 136 | LastCompactionCost float64 `json:"last_compaction_cost,omitempty"` |
| 137 | Generation uint64 `json:"generation,omitempty"` |
| 138 | LastReceipt *ContextMaintenanceReceipt `json:"last_receipt,omitempty"` |
| 139 | BlockedInputHash string `json:"blocked_input_hash,omitempty"` |
| 140 | BlockedReason string `json:"blocked_reason,omitempty"` |
| 141 | // NativeContextEditingAccepted latches the first successful native request. |
| 142 | // ContextEditingFallbackLocal persists the only allowed request-shape switch: |
| 143 | // an explicit unsupported response before that latch was set. |
| 144 | NativeContextEditingAccepted bool `json:"native_context_editing_accepted,omitempty"` |
| 145 | ContextEditingFallbackLocal bool `json:"context_editing_fallback_local,omitempty"` |
| 146 | UpdatedAt time.Time `json:"updated_at"` |
| 147 | } |
| 148 | |
| 149 | // CompactionTelemetry is the structured observability record for one |
| 150 | // compaction attempt. Sensitive transcript content is intentionally omitted. |
| 151 | type CompactionTelemetry struct { |
| 152 | Trigger string `json:"trigger"` |
| 153 | CacheState string `json:"cache_state"` |
| 154 | Mode string `json:"mode"` |
| 155 | Native bool `json:"native"` |
| 156 | SourceTokens int `json:"source_tokens"` |
| 157 | FoldTokens int `json:"fold_tokens"` // summarizer input after any shortening |
| 158 | Spans int `json:"spans"` // summarizer calls the fold needed; 1 unless it was split |
| 159 | ProjectionTokens int `json:"projection_tokens"` |
| 160 | UserTurnsKept int `json:"user_turns_kept"` |
| 161 | UserTurnsDropped int `json:"user_turns_dropped"` // past the retention budget, now summary-only |
| 162 | InputTokens int `json:"input_tokens"` |
| 163 | OutputTokens int `json:"output_tokens"` |
| 164 | CacheHitTokens int `json:"cache_hit_tokens"` |
| 165 | CacheMissTokens int `json:"cache_miss_tokens"` |
| 166 | CacheWriteTokens int `json:"cache_write_tokens"` |
| 167 | RequestCount int `json:"request_count"` |
| 168 | ProviderRequestID string `json:"provider_request_id,omitempty"` |
| 169 | SummaryInputMode string `json:"summary_input_mode,omitempty"` |
| 170 | Error string `json:"error,omitempty"` |
| 171 | } |
| 172 | |
| 173 | // ContextStatePath returns the projection sidecar path for a session transcript. |
| 174 | func ContextStatePath(sessionPath string) string { |
| 175 | return store.SessionContext(sessionPath) |
| 176 | } |
| 177 | |
| 178 | // LoadCompactionState reads the context sidecar. Missing files return ok=false. |
| 179 | // Corrupt or unsupported schema returns an error so callers can drop and rebuild. |
| 180 | func LoadCompactionState(sessionPath string) (CompactionState, bool, error) { |
| 181 | path := ContextStatePath(sessionPath) |
| 182 | if path == "" { |
| 183 | return CompactionState{}, false, nil |
| 184 | } |
| 185 | b, err := os.ReadFile(path) |
| 186 | if err != nil { |
| 187 | if os.IsNotExist(err) { |
| 188 | return CompactionState{}, false, nil |
| 189 | } |
| 190 | return CompactionState{}, false, err |
| 191 | } |
| 192 | var st CompactionState |
| 193 | if err := json.Unmarshal(b, &st); err != nil { |
| 194 | return CompactionState{}, false, fmt.Errorf("decode context state %s: %w", path, err) |
| 195 | } |
| 196 | if st.SchemaVersion != 0 && st.SchemaVersion != compactionStateSchemaV1 && st.SchemaVersion != compactionStateSchemaV2 && |
| 197 | st.SchemaVersion != compactionStateSchemaV3 && st.SchemaVersion != compactionStateSchemaV4 { |
| 198 | return CompactionState{}, false, fmt.Errorf("unsupported context schema version %d", st.SchemaVersion) |
| 199 | } |
| 200 | if st.SchemaVersion == 0 { |
| 201 | st.SchemaVersion = compactionStateSchemaV1 |
| 202 | } |
| 203 | return st, true, nil |
| 204 | } |
| 205 | |
| 206 | // SaveCompactionState writes the sidecar via strict atomic publish (temp + |
| 207 | // file fsync + rename + best-effort parent-dir fsync). Checkpoint sidecars are |
| 208 | // commit pointers: EXDEV/copy fallbacks that can tear an existing file are |
| 209 | // rejected so a failed write leaves the previous checkpoint intact. A returned |
| 210 | // error means the on-disk pointer was not published. |
| 211 | func SaveCompactionState(sessionPath string, st CompactionState) error { |
| 212 | path := ContextStatePath(sessionPath) |
| 213 | if path == "" { |
| 214 | return fmt.Errorf("empty session path") |
| 215 | } |
| 216 | // V4 adds a canonical-coverage pinned-context checkpoint. Previous readers |
| 217 | // fail closed on the unknown schema and replay canonical history. |
| 218 | st.SchemaVersion = compactionStateSchemaCurrent |
| 219 | if st.UpdatedAt.IsZero() { |
| 220 | st.UpdatedAt = time.Now().UTC() |
| 221 | } |
| 222 | // LastReceipt is authoritative. Drop mirrored top-level last_*/blocked_* |
| 223 | // writer fields so new sidecars do not re-emit the pre-v3 dual schema. |
| 224 | // Old files with those keys still decode into the struct for readers. |
| 225 | st.LastTrigger = "" |
| 226 | st.LastMode = "" |
| 227 | st.LastSourceTokens = 0 |
| 228 | st.LastResultTokens = 0 |
| 229 | st.LastCompactionCost = 0 |
| 230 | if st.LastReceipt != nil { |
| 231 | st.BlockedInputHash = "" |
| 232 | st.BlockedReason = "" |
| 233 | } |
| 234 | b, err := json.MarshalIndent(st, "", " ") |
| 235 | if err != nil { |
| 236 | return err |
| 237 | } |
| 238 | b = append(b, '\n') |
| 239 | return fileutil.AtomicWriteFileStrict(path, b, 0o644) |
| 240 | } |
| 241 | |
| 242 | // RemoveCompactionState deletes a corrupt or invalidated projection sidecar. |
| 243 | func RemoveCompactionState(sessionPath string) error { |
| 244 | path := ContextStatePath(sessionPath) |
| 245 | if path == "" { |
| 246 | return nil |
| 247 | } |
| 248 | err := os.Remove(path) |
| 249 | if err != nil && !os.IsNotExist(err) { |
| 250 | return err |
| 251 | } |
| 252 | return nil |
| 253 | } |
| 254 | |
| 255 | // summaryContentHash fingerprints a compaction summary for projection metadata. |
| 256 | func summaryContentHash(summary string) string { |
| 257 | if summary == "" { |
| 258 | return "" |
| 259 | } |
| 260 | sum := sha256.Sum256([]byte(summary)) |
| 261 | return hex.EncodeToString(sum[:16]) |
| 262 | } |
| 263 | |
| 264 | // coveredPrefixHash fingerprints the current model-visible prefix of msgs[:n]. |
| 265 | // Tool Content is the stable bounded provider representation; RawContent is |
| 266 | // local-only. SanitizeToolPairing applies the same deterministic repair used on |
| 267 | // the wire, keeping hashes stable when LoadSession repairs a transcript. |
| 268 | func coveredPrefixHash(msgs []provider.Message, n int) string { |
| 269 | if n <= 0 || n > len(msgs) { |
| 270 | return "" |
| 271 | } |
| 272 | visible := modelInputMessages(msgs[:n]) |
| 273 | return providerVisibleFingerprint(provider.SanitizeToolPairing(visible)) |
| 274 | } |
| 275 | |
| 276 | // boundedCoveredPrefixHash is the v3 bounded provider fingerprint. Keep the |
| 277 | // named helper for old sidecar and load-repair compatibility tests. |
| 278 | func boundedCoveredPrefixHash(msgs []provider.Message, n int) string { |
| 279 | if n <= 0 || n > len(msgs) { |
| 280 | return "" |
| 281 | } |
| 282 | visible := provider.ModelMessages(msgs[:n]) |
| 283 | return providerVisibleFingerprint(provider.SanitizeToolPairing(visible)) |
| 284 | } |
| 285 | |
| 286 | // promotedCoveredPrefixHash reproduces the temporary v3 behavior that promoted |
| 287 | // full tool RawContent into every provider request. |
| 288 | func promotedCoveredPrefixHash(msgs []provider.Message, n int) string { |
| 289 | if n <= 0 || n > len(msgs) { |
| 290 | return "" |
| 291 | } |
| 292 | promoted := append([]provider.Message(nil), msgs[:n]...) |
| 293 | for i := range promoted { |
| 294 | if promoted[i].Role == provider.RoleTool && promoted[i].RawContent != "" { |
| 295 | promoted[i].Content = promoted[i].RawContent |
| 296 | } |
| 297 | } |
| 298 | return providerVisibleFingerprint(provider.SanitizeToolPairing(provider.ModelMessages(promoted))) |
| 299 | } |
| 300 | |
| 301 | // normalizePromotedProjectionToolBodies converts the provider-visible tool |
| 302 | // bodies persisted by the temporary RawContent-promoting implementation back |
| 303 | // to canonical bounded Content. Every tool message must match a canonical tool |
| 304 | // result exactly by identity and old provider-visible body. Duplicate call IDs |
| 305 | // are safe only when every matching candidate maps to the same bounded body. |
| 306 | func normalizePromotedProjectionToolBodies(projection, canonical []provider.Message, n int) ([]provider.Message, bool) { |
| 307 | if n <= 0 || n > len(canonical) { |
| 308 | return nil, false |
| 309 | } |
| 310 | normalized := append([]provider.Message(nil), projection...) |
| 311 | for i, projected := range normalized { |
| 312 | if projected.Role != provider.RoleTool { |
| 313 | continue |
| 314 | } |
| 315 | visibleBody := projected.Content |
| 316 | if projected.ProviderContent != "" { |
| 317 | visibleBody = projected.ProviderContent |
| 318 | } |
| 319 | boundedBody := "" |
| 320 | matched := false |
| 321 | for _, candidate := range canonical[:n] { |
| 322 | if candidate.Role != provider.RoleTool || candidate.ToolCallID != projected.ToolCallID || candidate.Name != projected.Name { |
| 323 | continue |
| 324 | } |
| 325 | matchesBounded := visibleBody == candidate.Content |
| 326 | matchesPromoted := candidate.RawContent != "" && visibleBody == candidate.RawContent |
| 327 | if !matchesBounded && !matchesPromoted { |
| 328 | continue |
| 329 | } |
| 330 | if matched && boundedBody != candidate.Content { |
| 331 | return nil, false |
| 332 | } |
| 333 | boundedBody = candidate.Content |
| 334 | matched = true |
| 335 | } |
| 336 | if !matched { |
| 337 | return nil, false |
| 338 | } |
| 339 | normalized[i].Content = boundedBody |
| 340 | normalized[i].RawContent = "" |
| 341 | normalized[i].ProviderContent = "" |
| 342 | } |
| 343 | return normalized, true |
| 344 | } |
| 345 | |
| 346 | // migratePromotedCoveredPrefixHash normalizes a sidecar written while full tool |
| 347 | // RawContent was model-visible. Migration is exact and atomic: both its hash and |
| 348 | // retained tool bodies must match the historical form. Unrelated, stale, or |
| 349 | // ambiguous sidecars stay invalid so callers drop only their projection body. |
| 350 | func migratePromotedCoveredPrefixHash(st *CompactionState, msgs []provider.Message) bool { |
| 351 | if st == nil { |
| 352 | return false |
| 353 | } |
| 354 | n := st.Projection.CoveredCount |
| 355 | stored := st.Projection.CoveredPrefixHash |
| 356 | currentHash := coveredPrefixHash(msgs, n) |
| 357 | if stored == "" || currentHash == "" || stored == currentHash || |
| 358 | stored != promotedCoveredPrefixHash(msgs, n) { |
| 359 | return false |
| 360 | } |
| 361 | normalizedMessages, ok := normalizePromotedProjectionToolBodies(st.Projection.Messages, msgs, n) |
| 362 | if !ok { |
| 363 | return false |
| 364 | } |
| 365 | st.Projection.Messages = normalizedMessages |
| 366 | st.Projection.CoveredPrefixHash = currentHash |
| 367 | if st.LastReceipt != nil && st.LastReceipt.CoveredPrefixHash == stored { |
| 368 | receipt := *st.LastReceipt |
| 369 | receipt.CoveredPrefixHash = currentHash |
| 370 | st.LastReceipt = &receipt |
| 371 | } |
| 372 | return true |
| 373 | } |
| 374 | |
| 375 | // legacyCoveredPrefixHash reproduces the v1.25.2 fingerprint. It is used only |
| 376 | // to migrate a sidecar whose persisted pre-repair transcript is still available; |
| 377 | // new checkpoints always use coveredPrefixHash. |
| 378 | func legacyCoveredPrefixHash(msgs []provider.Message, n int) string { |
| 379 | if n <= 0 || n > len(msgs) { |
| 380 | return "" |
| 381 | } |
| 382 | return providerVisibleFingerprint(provider.ModelMessages(msgs[:n])) |
| 383 | } |
| 384 | |
| 385 | // migrateLegacyCoveredPrefixHash upgrades a v1.25.2 sidecar after LoadSession |
| 386 | // performed a deterministic provider-visible repair. It is deliberately strict: |
| 387 | // the stored legacy hash must match the exact pre-repair disk prefix, and that |
| 388 | // prefix's wire-safe form must equal the current prefix. A real history or |
| 389 | // system-prompt change therefore remains invalid. |
| 390 | func migrateLegacyCoveredPrefixHash(st *CompactionState, current, preRepair []provider.Message) bool { |
| 391 | if st == nil || len(preRepair) == 0 { |
| 392 | return false |
| 393 | } |
| 394 | n := st.Projection.CoveredCount |
| 395 | stored := st.Projection.CoveredPrefixHash |
| 396 | if stored == "" || legacyCoveredPrefixHash(preRepair, n) != stored { |
| 397 | return false |
| 398 | } |
| 399 | preRepairWireHash := boundedCoveredPrefixHash(preRepair, n) |
| 400 | currentHash := coveredPrefixHash(current, n) |
| 401 | if currentHash == "" || preRepairWireHash != boundedCoveredPrefixHash(current, n) { |
| 402 | return false |
| 403 | } |
| 404 | st.Projection.CoveredPrefixHash = currentHash |
| 405 | if st.LastReceipt != nil && st.LastReceipt.CoveredPrefixHash == stored { |
| 406 | receipt := *st.LastReceipt |
| 407 | receipt.CoveredPrefixHash = currentHash |
| 408 | st.LastReceipt = &receipt |
| 409 | } |
| 410 | return true |
| 411 | } |
| 412 | |
| 413 | // providerVisibleFingerprint is the stable hash of fields that reach a provider. |
| 414 | func providerVisibleFingerprint(msgs []provider.Message) string { |
| 415 | type wireCall struct { |
| 416 | ID string `json:"id,omitempty"` |
| 417 | Name string `json:"name,omitempty"` |
| 418 | Arguments string `json:"args,omitempty"` |
| 419 | ThoughtSignature string `json:"ts,omitempty"` |
| 420 | } |
| 421 | type wireMsg struct { |
| 422 | Role string `json:"r"` |
| 423 | Content string `json:"c,omitempty"` |
| 424 | Images []string `json:"img,omitempty"` |
| 425 | ImageInputs []attachment.ImageInput `json:"ii,omitempty"` |
| 426 | ReasoningContent string `json:"rc,omitempty"` |
| 427 | ReasoningID string `json:"rid,omitempty"` |
| 428 | ReasoningStatus string `json:"rst,omitempty"` |
| 429 | ReasoningSignature string `json:"rsig,omitempty"` |
| 430 | ToolCallID string `json:"tid,omitempty"` |
| 431 | Name string `json:"n,omitempty"` |
| 432 | ToolCalls []wireCall `json:"tc,omitempty"` |
| 433 | ThinkingBlocks []provider.ThinkingBlock `json:"tb,omitempty"` |
| 434 | ResponsesItems []json.RawMessage `json:"ri,omitempty"` |
| 435 | ServerSearch []provider.ServerSearchCall `json:"ss,omitempty"` |
| 436 | } |
| 437 | wire := make([]wireMsg, 0, len(msgs)) |
| 438 | for _, m := range msgs { |
| 439 | wm := wireMsg{ |
| 440 | Role: string(m.Role), |
| 441 | Content: m.Content, |
| 442 | Images: append([]string(nil), m.Images...), |
| 443 | ImageInputs: attachment.CloneImageInputs(m.ImageInputs), |
| 444 | ReasoningContent: m.ReasoningContent, |
| 445 | ReasoningID: m.ReasoningID, |
| 446 | ReasoningStatus: m.ReasoningStatus, |
| 447 | ReasoningSignature: m.ReasoningSignature, |
| 448 | ThinkingBlocks: m.ThinkingBlocks, |
| 449 | ToolCallID: m.ToolCallID, |
| 450 | Name: m.Name, |
| 451 | } |
| 452 | for _, tc := range m.ToolCalls { |
| 453 | wm.ToolCalls = append(wm.ToolCalls, wireCall{ |
| 454 | ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments, ThoughtSignature: tc.ThoughtSignature, |
| 455 | }) |
| 456 | } |
| 457 | if len(m.ResponsesItems) > 0 { |
| 458 | wm.ResponsesItems = make([]json.RawMessage, len(m.ResponsesItems)) |
| 459 | for i, item := range m.ResponsesItems { |
| 460 | wm.ResponsesItems[i] = append(json.RawMessage(nil), item...) |
| 461 | } |
| 462 | } |
| 463 | if len(m.ServerSearch) > 0 { |
| 464 | wm.ServerSearch = append([]provider.ServerSearchCall(nil), m.ServerSearch...) |
| 465 | } |
| 466 | wire = append(wire, wm) |
| 467 | } |
| 468 | b, err := json.Marshal(wire) |
| 469 | if err != nil { |
| 470 | return "" |
| 471 | } |
| 472 | sum := sha256.Sum256(b) |
| 473 | return hex.EncodeToString(sum[:16]) |
| 474 | } |
| 475 | |
| 476 | // projectionValid reports whether st can be reused for the current transcript |
| 477 | // and provider/model lineage. Fail closed: missing CoveredPrefixHash or a blank |
| 478 | // sidecar PromptCacheKey when the current lineage key is known forces rebuild. |
| 479 | func projectionValid(st CompactionState, msgs []provider.Message, cacheKey string) bool { |
| 480 | if len(st.Projection.Messages) == 0 { |
| 481 | return false |
| 482 | } |
| 483 | // Current lineage known: stored key must match (legacy native suffix ok). |
| 484 | if cacheKey != "" { |
| 485 | if _, ok := lineageKeyCompatible(st.PromptCacheKey, cacheKey); !ok { |
| 486 | return false |
| 487 | } |
| 488 | } |
| 489 | return projectionContentValid(st, msgs) |
| 490 | } |
| 491 | |
| 492 | // projectionContentValid reports whether st's projection body still matches the |
| 493 | // canonical transcript, independent of provider/model lineage. The covered hash |
| 494 | // is authoritative, except for leading system messages: those live outside the |
| 495 | // folded region and may be refreshed independently after a compaction. |
| 496 | func projectionContentValid(st CompactionState, msgs []provider.Message) bool { |
| 497 | if len(st.Projection.Messages) == 0 { |
| 498 | return false |
| 499 | } |
| 500 | n := st.Projection.CoveredCount |
| 501 | if n <= 0 || n > len(msgs) { |
| 502 | return false |
| 503 | } |
| 504 | // Prefix hash is required; legacy sidecars without it are rebuilt. |
| 505 | if st.Projection.CoveredPrefixHash == "" { |
| 506 | return false |
| 507 | } |
| 508 | // Readers before v4 did not authenticate pinned revision provenance. Their |
| 509 | // projections may summarize or omit active pinned state, so fail closed and |
| 510 | // rebuild a v4 checkpoint from the canonical transcript. |
| 511 | if st.SchemaVersion < compactionStateSchemaV4 && containsPinnedContextRevision(msgs[:n]) { |
| 512 | return false |
| 513 | } |
| 514 | if st.SchemaVersion >= compactionStateSchemaV4 && |
| 515 | st.Projection.PinnedContextHash != pinnedContextCoverageHash(msgs, n) { |
| 516 | return false |
| 517 | } |
| 518 | if coveredPrefixHash(msgs, n) == st.Projection.CoveredPrefixHash { |
| 519 | return true |
| 520 | } |
| 521 | return projectionMatchesAfterSystemRefresh(st, msgs, n) |
| 522 | } |
| 523 | |
| 524 | // projectionMatchesAfterSystemRefresh verifies a covered-prefix mismatch by |
| 525 | // substituting the projection's previous leading system messages into the |
| 526 | // current canonical prefix. A match proves that only the dynamic system prompt |
| 527 | // changed; any user, assistant, tool, image, or signed-reasoning edit still |
| 528 | // fails closed. |
| 529 | func projectionMatchesAfterSystemRefresh(st CompactionState, msgs []provider.Message, n int) bool { |
| 530 | if n <= 0 || n > len(msgs) || len(st.Projection.Messages) == 0 { |
| 531 | return false |
| 532 | } |
| 533 | candidate := append([]provider.Message(nil), msgs[:n]...) |
| 534 | systems := 0 |
| 535 | for systems < len(candidate) && candidate[systems].Role == provider.RoleSystem { |
| 536 | if systems >= len(st.Projection.Messages) || st.Projection.Messages[systems].Role != provider.RoleSystem { |
| 537 | return false |
| 538 | } |
| 539 | candidate[systems] = st.Projection.Messages[systems] |
| 540 | systems++ |
| 541 | } |
| 542 | if systems == 0 || (systems < len(st.Projection.Messages) && st.Projection.Messages[systems].Role == provider.RoleSystem) { |
| 543 | return false |
| 544 | } |
| 545 | return coveredPrefixHash(candidate, len(candidate)) == st.Projection.CoveredPrefixHash |
| 546 | } |
| 547 | |
| 548 | // modelVisibleFromProjection splices the projection with any messages appended |
| 549 | // after it was built. LocalOnly messages stay excluded via ModelMessages later. |
| 550 | func modelVisibleFromProjection(proj ContextProjection, canonical []provider.Message) []provider.Message { |
| 551 | if len(proj.Messages) == 0 { |
| 552 | return nil |
| 553 | } |
| 554 | out := append([]provider.Message(nil), proj.Messages...) |
| 555 | // Leading system messages are outside every fold. Refresh them from canonical |
| 556 | // so memory/tool/environment prompt updates do not serve a stale prefix or |
| 557 | // force a full-history replay. |
| 558 | for i := 0; i < len(canonical) && i < len(out) && canonical[i].Role == provider.RoleSystem && out[i].Role == provider.RoleSystem; i++ { |
| 559 | out[i] = canonical[i] |
| 560 | } |
| 561 | if proj.CoveredCount >= 0 && proj.CoveredCount < len(canonical) { |
| 562 | out = append(out, canonical[proj.CoveredCount:]...) |
| 563 | } |
| 564 | return out |
| 565 | } |
| 566 | |
| 567 | // coalesceProjectionUserRuns keeps provider request copies compatible with |
| 568 | // providers that require strict user/assistant alternation. Projection |
| 569 | // sidecars retain logical user-turn boundaries; only the outbound copy is |
| 570 | // merged, leaving canonical history and range anchors untouched. |
| 571 | func coalesceProjectionUserRuns(msgs []provider.Message) []provider.Message { |
| 572 | if len(msgs) < 2 { |
| 573 | return msgs |
| 574 | } |
| 575 | out := make([]provider.Message, 0, len(msgs)) |
| 576 | for _, msg := range msgs { |
| 577 | if len(out) == 0 || msg.Role != provider.RoleUser || out[len(out)-1].Role != provider.RoleUser { |
| 578 | clone := msg |
| 579 | clone.Images = append([]string(nil), msg.Images...) |
| 580 | clone.ImageInputs = provider.CloneImageInputs(msg.ImageInputs) |
| 581 | clone.ToolCalls = append([]provider.ToolCall(nil), msg.ToolCalls...) |
| 582 | clone.ResponsesItems = append([]json.RawMessage(nil), msg.ResponsesItems...) |
| 583 | clone.ServerSearch = append([]provider.ServerSearchCall(nil), msg.ServerSearch...) |
| 584 | out = append(out, clone) |
| 585 | continue |
| 586 | } |
| 587 | |
| 588 | prev := &out[len(out)-1] |
| 589 | if isCompactionSummary(msg) && !isCompactionSummary(*prev) { |
| 590 | prev.Content = strings.TrimRight(msg.Content, "\n") + "\n\n" + prev.Content |
| 591 | } else { |
| 592 | prev.Content = strings.TrimRight(prev.Content, "\n") + "\n\n" + msg.Content |
| 593 | } |
| 594 | prev.Images = append(prev.Images, msg.Images...) |
| 595 | prev.ImageInputs = append(prev.ImageInputs, provider.CloneImageInputs(msg.ImageInputs)...) |
| 596 | prev.ToolCalls = append(prev.ToolCalls, msg.ToolCalls...) |
| 597 | prev.ResponsesItems = append(prev.ResponsesItems, msg.ResponsesItems...) |
| 598 | prev.ServerSearch = append(prev.ServerSearch, msg.ServerSearch...) |
| 599 | } |
| 600 | return out |
| 601 | } |
| 602 | |
| 603 | // formatSummaryMessage builds the stable user-turn wrapper around a digest. |
| 604 | func formatSummaryMessage(summary string) provider.Message { |
| 605 | return provider.Message{ |
| 606 | Role: provider.RoleUser, Origin: provider.MessageOriginHost, |
| 607 | Content: summaryTagOpen + "\n" + |
| 608 | "Summary of earlier conversation (older messages were compacted to save context):\n" + |
| 609 | summary + "\n" + |
| 610 | summaryTagClose, |
| 611 | } |
| 612 | } |
| 613 |