返回 DeepSeek-Reasonix
projection.go
根目录 / internal / agent / projection.go
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
613 lines GO