返回 DeepSeek-Reasonix
session.go
根目录 / internal / agent / session.go
1 // Package agent wires a Provider, a tool Registry, and a Session into the
2 // harness loop that drives a coding task to completion.
3 package agent
4
5 import (
6 "bytes"
7 "slices"
8 "strings"
9 "sync"
10
11 "reasonix/internal/provider"
12 )
13
14 // Session holds the conversation history for one task. The run loop (one turn at
15 // a time) is the only writer, but a frontend can read History/Save from another
16 // goroutine while a turn appends, so mu guards Messages. Direct Messages reads on
17 // the run-loop goroutine stay lock-free (serial with its own writes); cross-
18 // goroutine access goes through Snapshot.
19 type Session struct {
20 cacheSessionID string // ephemeral transport identity; never model-visible or persisted
21 mu sync.RWMutex
22 Messages []provider.Message
23 version uint64
24 recoveryMetadataVersion uint64 // local receipt edits require persistence, not a model-history rewrite
25 rewriteVersion int // bumped each time the log is rewritten (compact/fold)
26 // persistedRewriteVersion is the highest rewriteVersion whose transcript
27 // has fully reached disk. It lives on the Session — not on the controller
28 // — so swapping session objects can never orphan or misattribute the
29 // baseline: NeedsRewriteSave always compares a session against its own
30 // save history. Save paths advance it under s.mu with the rewriteVersion
31 // captured alongside the message snapshot, never a re-read one, so a
32 // compaction landing mid-save stays unpersisted.
33 persistedRewriteVersion int
34 persisted sessionPersistState
35 // normalizedDirty is set when LoadSession repaired the history on the way in
36 // (empty tool-call names, dangling calls, truncated args, …). The repair
37 // already lives in Messages, so the next Save persists it automatically as
38 // part of the usual full rewrite; the flag exists for observability and to
39 // let callers opt out of work that a dirty session would make redundant.
40 normalizedDirty bool
41 // eventLogDamaged is set when LoadSession found the on-disk event log torn
42 // or corrupt and returned the replayable prefix (or the .jsonl checkpoint).
43 // The next save heals the log with a rewrite-and-compact.
44 eventLogDamaged bool
45 // rawMessages preserves the pre-normalization transcript when the load-time
46 // repairs changed it (normalizedDirty). It is only meaningful on a freshly
47 // loaded Session: checkSnapshotWrite compares a pending snapshot against
48 // what is actually on disk, and the repaired view no longer represents
49 // those bytes — a session that kept running extends the raw transcript.
50 rawMessages []provider.Message
51 // pendingContentReasons accumulates a reason string each time Rewrite()
52 // actually replaces provider-visible message bytes (compact, prune/snip,
53 // summarize, rewind, guardian merge). ReplaceLocalMetadata bumps
54 // rewriteVersion for the same save-path (NeedsRewriteSave) purpose without
55 // appending here, because ModelMessages strips or never serializes the
56 // local-only metadata it changes — so that path must never report a
57 // cache-prefix change. DrainContentRewriteReasons (run_loop.go, once per
58 // provider request) is the sole consumer.
59 pendingContentReasons []string
60 // persistObserver receives non-blocking post-commit projection hints. It is
61 // deliberately session-local so multiple runtimes cannot steal each other's
62 // observer registration.
63 persistObserver SessionPersistObserver
64 // writeAuth is the generation-bound write permit for this session's path.
65 // Controllers bind it after acquiring a SessionLease; save/ownership paths
66 // consult it instead of a process-level "I hold a lease" boolean.
67 writeAuth *SessionWriteAuthority
68 // authRequired becomes true once any authority has been bound. From then
69 // on, saves fail closed without a live authority rather than forking
70 // recovery under a stale controller.
71 authRequired bool
72 // persistedMessages is the last paired on-disk view for persistedViewPath.
73 persistedMessages []provider.Message
74 // persistedViewPath is empty when the persist baseline has no paired view.
75 persistedViewPath string
76 // recoveryLane is a session-instance identity, allocated lazily on the
77 // first true conflict. It bounds repeated saves by this live controller to
78 // one recovery file without letting a replacement controller overwrite it.
79 recoveryLane string
80 head sessionHeadState
81 }
82
83 // NewSession initializes a session with an optional system prompt.
84 func NewSession(system string) *Session {
85 s := &Session{}
86 if system != "" {
87 s.Messages = append(s.Messages, provider.Message{Role: provider.RoleSystem, Content: system, ID: NewMessageID()})
88 }
89 return s
90 }
91
92 // Add appends a message.
93 func (s *Session) Add(m provider.Message) {
94 s.mu.Lock()
95 defer s.mu.Unlock()
96 if m.ID == "" {
97 m.ID = NewMessageID()
98 }
99 s.expireProtocolRecoveryLocked([]provider.Message{m})
100 s.Messages = append(s.Messages, m)
101 s.version++
102 }
103
104 // AddBatch appends one logical transcript batch under a single lock. Turn
105 // admission uses it for an optional host context revision plus the real user
106 // message so autosave can never observe only half of the admitted boundary.
107 func (s *Session) AddBatch(messages ...provider.Message) {
108 if s == nil || len(messages) == 0 {
109 return
110 }
111 s.mu.Lock()
112 mintMessageIDs(messages)
113 s.expireProtocolRecoveryLocked(messages)
114 s.Messages = append(s.Messages, messages...)
115 s.version++
116 s.mu.Unlock()
117 }
118
119 // SetLeadingSystemPrompt updates or sets the leading system prompt message.
120 func (s *Session) SetLeadingSystemPrompt(prompt string) {
121 s.SetLeadingSystemPromptWithReason(prompt, "system_prompt_refresh")
122 }
123
124 // SetLeadingSystemPromptWithReason refreshes the authoritative system prompt
125 // and records the provider-visible rewrite boundary exactly once. It is used
126 // for low-frequency host prompt migrations, never ordinary pinned-context
127 // updates (which append user-role revisions instead).
128 func (s *Session) SetLeadingSystemPromptWithReason(prompt, reason string) bool {
129 if s == nil {
130 return false
131 }
132 s.mu.Lock()
133 defer s.mu.Unlock()
134 if len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem {
135 if s.Messages[0].Content == prompt {
136 return false
137 }
138 s.Messages[0].Content = prompt
139 } else if prompt != "" {
140 s.Messages = append([]provider.Message{{Role: provider.RoleSystem, Content: prompt, ID: NewMessageID()}}, s.Messages...)
141 } else {
142 return false
143 }
144 s.rewriteVersion++
145 if reason = strings.TrimSpace(reason); reason != "" {
146 s.pendingContentReasons = append(s.pendingContentReasons, reason)
147 }
148 s.version++
149 return true
150 }
151
152 // ConsumeFinalReadinessRecovery marks the newest pending readiness checkpoint
153 // consumed before any next user turn (explicit recovery or ordinary follow-up).
154 // This is local metadata only, so the rewrite does not alter provider bytes or
155 // prompt-cache identity.
156 func (s *Session) ConsumeFinalReadinessRecovery() bool {
157 if s == nil {
158 return false
159 }
160 s.mu.Lock()
161 defer s.mu.Unlock()
162 for i := range slices.Backward(s.Messages) {
163 message := &s.Messages[i]
164 if message.LocalOnly && message.FinalReadinessRecovery != nil && message.FinalReadinessRecovery.Pending {
165 consumed := *message.FinalReadinessRecovery
166 consumed.Pending = false
167 consumed.Missing = append([]string(nil), consumed.Missing...)
168 consumed.Checkpoint = append([]byte(nil), consumed.Checkpoint...)
169 message.FinalReadinessRecovery = &consumed
170 s.rewriteVersion++
171 s.version++
172 return true
173 }
174 if IsUserAuthoredTurnMessage(*message) {
175 return false
176 }
177 }
178 return false
179 }
180
181 // AddDecisionReceipt persists local decision metadata without inserting a
182 // standalone message into the current tool turn. Tool results must remain
183 // directly adjacent to the assistant message that requested them; otherwise
184 // session normalization fabricates interrupted placeholders and older readers
185 // can lose the real result. Attaching to the newest assistant message keeps the
186 // provider-visible transcript byte-for-byte equivalent after ModelMessages.
187 //
188 // The fallback sentinel covers host decisions made before any assistant message
189 // exists. Older readers already discard this unmatched tool record safely.
190 func (s *Session) AddDecisionReceipt(receipt *provider.DecisionReceipt) {
191 if s == nil || receipt == nil {
192 return
193 }
194 s.mu.Lock()
195 defer s.mu.Unlock()
196 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
197 for i := len(s.Messages) - 1; i >= 0; i-- {
198 if IsUserAuthoredTurnMessage(s.Messages[i]) {
199 break
200 }
201 if s.Messages[i].Role != provider.RoleAssistant || s.Messages[i].LocalOnly {
202 continue
203 }
204 receipts := append([]*provider.DecisionReceipt(nil), s.Messages[i].DecisionReceipts...)
205 s.Messages[i].DecisionReceipts = append(receipts, receipt)
206 // A mid-turn snapshot may already contain this assistant message. Force
207 // the next save to replace it instead of treating the later tool result
208 // as the only append-only change.
209 s.rewriteVersion++
210 s.version++
211 return
212 }
213 s.Messages = append(s.Messages, provider.Message{
214 Role: provider.RoleTool,
215 ToolCallID: provider.LocalOnlyToolID,
216 Name: provider.LocalOnlyToolName,
217 LocalOnly: true,
218 DecisionReceipt: receipt,
219 })
220 s.version++
221 }
222
223 // UpdateToolCallPreview replaces the preview fields of the newest matching
224 // assistant tool call. A dependent writer can only be previewed after an
225 // earlier writer in the same model batch succeeds; updating under the session
226 // lock keeps live History/Snapshot readers race-free and ensures the refreshed
227 // preview is what a resumed session archives.
228 func (s *Session) UpdateToolCallPreview(call provider.ToolCall) bool {
229 if call.ID == "" {
230 return false
231 }
232 s.mu.Lock()
233 defer s.mu.Unlock()
234 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
235 for i := len(s.Messages) - 1; i >= 0; i-- {
236 if s.Messages[i].Role != provider.RoleAssistant {
237 continue
238 }
239 calls := s.Messages[i].ToolCalls
240 for j := range calls {
241 if calls[j].ID != call.ID {
242 continue
243 }
244 cloned := append([]provider.ToolCall(nil), calls...)
245 cloned[j].Diff = call.Diff
246 cloned[j].Added = call.Added
247 cloned[j].Removed = call.Removed
248 s.Messages[i].ToolCalls = cloned
249 // A snapshot may have persisted the original assistant message while
250 // its tools were still running. Mark this as a rewrite so a later
251 // autosave replaces that message instead of misclassifying the tool
252 // results as an append-only suffix.
253 s.rewriteVersion++
254 s.version++
255 return true
256 }
257 }
258 return false
259 }
260
261 // UpdateToolCallResolution persists the host-resolved target metadata for the
262 // newest matching stable proxy call. The model-visible Name/Arguments remain
263 // unchanged; this metadata exists only so live and reloaded frontends classify
264 // MCP readers and writers accurately.
265 func (s *Session) UpdateToolCallResolution(call provider.ToolCall) bool {
266 if call.ID == "" || call.ResolvedReadOnly == nil {
267 return false
268 }
269 s.mu.Lock()
270 defer s.mu.Unlock()
271 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
272 for i := len(s.Messages) - 1; i >= 0; i-- {
273 if s.Messages[i].Role != provider.RoleAssistant {
274 continue
275 }
276 calls := s.Messages[i].ToolCalls
277 for j := range calls {
278 if calls[j].ID != call.ID {
279 continue
280 }
281 cloned := append([]provider.ToolCall(nil), calls...)
282 readOnly := *call.ResolvedReadOnly
283 cloned[j].ResolvedName = call.ResolvedName
284 cloned[j].CapabilityID = call.CapabilityID
285 cloned[j].ResolvedReadOnly = &readOnly
286 s.Messages[i].ToolCalls = cloned
287 // A mid-turn snapshot may already contain the unresolved proxy call.
288 // Force the next save to rewrite that assistant message with its
289 // resolved local metadata.
290 s.rewriteVersion++
291 s.version++
292 return true
293 }
294 }
295 return false
296 }
297
298 // Replace swaps the whole message log without classifying the change as a
299 // persisted-history rewrite. Call Rewrite when a live session changes messages
300 // that a mid-turn snapshot may already have written.
301 func (s *Session) Replace(msgs []provider.Message) {
302 s.mu.Lock()
303 defer s.mu.Unlock()
304 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
305 mintMessageIDs(msgs)
306 s.Messages = msgs
307 s.version++
308 }
309
310 // Rewrite atomically replaces the message log and marks it as a rewrite. The
311 // atomic classification matters when a periodic snapshot races compaction,
312 // pruning, or local metadata edits: a later autosave must use owned-rewrite
313 // conflict checks instead of mistaking the modified prefix for another writer.
314 //
315 // reason names the provider-visible change (e.g. "rewind_truncate",
316 // "guardian_merge") and is queued for the next DrainContentRewriteReasons
317 // call, which feeds cache-diagnostics attribution. Callers whose msgs only
318 // change local-only display metadata (never serialized to the provider) must
319 // use ReplaceLocalMetadata instead, so they don't misreport a cache-prefix
320 // change that never happened.
321 func (s *Session) Rewrite(msgs []provider.Message, reason string) {
322 s.mu.Lock()
323 defer s.mu.Unlock()
324 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
325 mintMessageIDs(msgs)
326 s.Messages = msgs
327 s.rewriteVersion++
328 s.version++
329 if reason != "" {
330 s.pendingContentReasons = append(s.pendingContentReasons, reason)
331 }
332 }
333
334 // ReplaceLocalMetadata atomically replaces the message log exactly like
335 // Rewrite (including the rewriteVersion bump that forces the next save to use
336 // owned-rewrite conflict checks), for callers that only changed local-only
337 // display metadata (e.g. marking a resubmitted message Edited) rather than any
338 // provider-visible byte. Unlike Rewrite, it never queues a cache-prefix-change
339 // reason, since ModelMessages strips or never serializes what changed.
340 func (s *Session) ReplaceLocalMetadata(msgs []provider.Message) {
341 s.mu.Lock()
342 defer s.mu.Unlock()
343 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
344 mintMessageIDs(msgs)
345 s.Messages = msgs
346 s.rewriteVersion++
347 s.version++
348 }
349
350 // DrainContentRewriteReasons returns and clears the reasons queued by Rewrite
351 // since the last drain. Called once per provider request (run_loop.go) so
352 // CompareShape can attribute a cache-prefix change to the operation that
353 // actually caused it.
354 func (s *Session) DrainContentRewriteReasons() []string {
355 s.mu.Lock()
356 defer s.mu.Unlock()
357 reasons := s.pendingContentReasons
358 s.pendingContentReasons = nil
359 return reasons
360 }
361
362 // NoteContentRewrite queues a provider-visible prefix-change reason without
363 // mutating Messages. Projection installs and resume-time system migrations use
364 // this so cache diagnostics attribute the next request's miss while the
365 // canonical transcript and its persistence baseline stay intact.
366 func (s *Session) NoteContentRewrite(reason string) {
367 if s == nil || reason == "" {
368 return
369 }
370 s.mu.Lock()
371 defer s.mu.Unlock()
372 s.pendingContentReasons = append(s.pendingContentReasons, reason)
373 }
374
375 // Snapshot returns a copy of the messages, safe to read from another goroutine
376 // while a turn appends. Frontends (History, Save) use it instead of touching the
377 // live slice.
378 func (s *Session) Snapshot() []provider.Message {
379 msgs, _, _ := s.snapshotWithVersion()
380 return msgs
381 }
382
383 // DisplayBaseline captures messages and their rewrite/head identity together.
384 // The controller calls this at an idle/admission boundary, before any new
385 // streaming event can commit to its display projection.
386 func (s *Session) DisplayBaseline() (messages []provider.Message, headID string, rewriteEpoch uint64) {
387 s.mu.RLock()
388 defer s.mu.RUnlock()
389 return append([]provider.Message(nil), s.Messages...), s.head.ref.HeadID, uint64(s.rewriteVersion)
390 }
391
392 // Len returns the number of messages, safe to call from any goroutine.
393 func (s *Session) Len() int {
394 s.mu.RLock()
395 defer s.mu.RUnlock()
396 return len(s.Messages)
397 }
398
399 // MessageRange returns a copy of the messages in [start, end), clamped to the
400 // current log bounds, safe to read from another goroutine while a turn
401 // appends. Paging frontends use it to fetch a display window without paying
402 // for a Snapshot of the whole history.
403 func (s *Session) MessageRange(start, end int) []provider.Message {
404 s.mu.RLock()
405 defer s.mu.RUnlock()
406 if start < 0 {
407 start = 0
408 }
409 if end > len(s.Messages) {
410 end = len(s.Messages)
411 }
412 if start >= end {
413 return []provider.Message{}
414 }
415 return append([]provider.Message(nil), s.Messages[start:end]...)
416 }
417
418 // CloneWithMessages returns a fresh Session carrying msgs while preserving the
419 // persistence baseline of the source session. Resume paths use this when they
420 // need to adjust loaded history before a rewrite; dropping persisted would make
421 // CAS treat the first legitimate rewrite as a stale-runtime conflict.
422 //
423 // Callers that are handed history from outside this Session should prefer
424 // CloneWithMessagesIfCompatible, so stale carried history cannot borrow a newer
425 // on-disk baseline.
426 func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
427 if s == nil {
428 return nil
429 }
430 s.mu.RLock()
431 defer s.mu.RUnlock()
432 version := s.version
433 if !messagesEqualForStorageList(s.Messages, msgs) {
434 version++
435 }
436 return &Session{
437 Messages: append([]provider.Message(nil), msgs...),
438 version: version,
439 recoveryMetadataVersion: s.recoveryMetadataVersion,
440 rewriteVersion: s.rewriteVersion,
441 persistedRewriteVersion: s.persistedRewriteVersion,
442 persisted: s.persisted,
443 normalizedDirty: s.normalizedDirty,
444 eventLogDamaged: s.eventLogDamaged,
445 rawMessages: append([]provider.Message(nil), s.rawMessages...),
446 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
447 }
448 }
449
450 // CloneWithMessagesIfCompatible preserves the persistence baseline only when
451 // msgs is the same persisted history, optionally with a refreshed leading system
452 // prompt. Other history changes must happen after Resume so SaveRewrite can
453 // still detect genuine stale-controller conflicts.
454 func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Session, bool) {
455 if s == nil {
456 return nil, false
457 }
458 s.mu.RLock()
459 defer s.mu.RUnlock()
460 if !messagesCompatibleForStorageBaseline(s.Messages, msgs) {
461 return nil, false
462 }
463 version := s.version
464 if !messagesEqualForStorageList(s.Messages, msgs) {
465 version++
466 }
467 return &Session{
468 Messages: append([]provider.Message(nil), msgs...),
469 version: version,
470 recoveryMetadataVersion: s.recoveryMetadataVersion,
471 rewriteVersion: s.rewriteVersion,
472 persistedRewriteVersion: s.persistedRewriteVersion,
473 persisted: s.persisted,
474 normalizedDirty: s.normalizedDirty,
475 eventLogDamaged: s.eventLogDamaged,
476 rawMessages: append([]provider.Message(nil), s.rawMessages...),
477 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
478 }, true
479 }
480
481 // projectionValidationMessages returns the current canonical transcript and,
482 // when LoadSession repaired it, the exact pre-repair disk view. Resume wrappers
483 // preserve both so projection sidecars can be migrated without weakening the
484 // covered-prefix check.
485 func (s *Session) projectionValidationMessages() (current, preRepair []provider.Message) {
486 if s == nil {
487 return nil, nil
488 }
489 s.mu.RLock()
490 defer s.mu.RUnlock()
491 current = append([]provider.Message(nil), s.Messages...)
492 if s.normalizedDirty && len(s.rawMessages) > 0 {
493 preRepair = append([]provider.Message(nil), s.rawMessages...)
494 }
495 return current, preRepair
496 }
497
498 // snapshotWithVersion returns the messages together with the version and
499 // rewriteVersion they were captured under, in one lock window: save paths
500 // persist exactly this rewriteVersion as the new baseline, so a rewrite that
501 // lands after the capture cannot be misrecorded as saved.
502 func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
503 s.mu.RLock()
504 defer s.mu.RUnlock()
505 return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
506 }
507
508 // snapshotMessagesVersion returns a copy of the messages with the transcript
509 // version, for projection validity checks that do not need rewriteVersion.
510 func (s *Session) snapshotMessagesVersion() ([]provider.Message, uint64) {
511 msgs, version, _ := s.snapshotWithVersion()
512 return msgs, version
513 }
514
515 // TranscriptVersion returns the current append/rewrite counter used by
516 // context-projection validity checks.
517 func (s *Session) TranscriptVersion() uint64 {
518 s.mu.RLock()
519 defer s.mu.RUnlock()
520 return s.version
521 }
522
523 // RewriteVersion returns the current rewrite version.
524 func (s *Session) RewriteVersion() int {
525 s.mu.RLock()
526 defer s.mu.RUnlock()
527 return s.rewriteVersion
528 }
529
530 // NeedsRewriteSave reports whether the message log was rewritten in place —
531 // rather than appended to — since the last successful full save of this
532 // session. Snapshot paths use it to decide that the next write must be an
533 // owned rewrite instead of an append.
534 func (s *Session) NeedsRewriteSave() bool {
535 s.mu.RLock()
536 defer s.mu.RUnlock()
537 return s.rewriteVersion > s.persistedRewriteVersion || s.recoveryMetadataVersion > s.persisted.version
538 }
539
540 // HasUnsavedChanges reports whether the in-memory transcript contains storage
541 // changes that have not been durably recorded at path. It is intentionally
542 // conservative when no verified baseline exists: an idle controller must not
543 // replace an in-memory conversation with a possibly older disk copy after a
544 // bounded lock failure or an interrupted save.
545 func (s *Session) HasUnsavedChanges(path string) bool {
546 if s == nil || strings.TrimSpace(path) == "" {
547 return false
548 }
549 msgs, _, rewriteVersion := s.snapshotWithVersion()
550 digest, err := digestSessionMessages(msgs)
551 if err != nil {
552 return true
553 }
554 key := canonicalSessionSavePath(path)
555 s.mu.RLock()
556 defer s.mu.RUnlock()
557 if !s.persisted.ok || s.persisted.path != key {
558 return true
559 }
560 if s.normalizedDirty || s.eventLogDamaged || rewriteVersion > s.persistedRewriteVersion {
561 return true
562 }
563 return !bytes.Equal(digest[:], s.persisted.digest[:])
564 }
565
566 // IncrementRewrite bumps the rewrite version by 1.
567 func (s *Session) IncrementRewrite() {
568 s.mu.Lock()
569 defer s.mu.Unlock()
570 s.rewriteVersion++
571 s.version++
572 }
573
574 // HasContent returns true when the session carries at least one user,
575 // assistant, or tool message — i.e. more than just a system prompt. An
576 // "empty" conversation that has never been used should not be persisted.
577 func (s *Session) HasContent() bool {
578 s.mu.RLock()
579 defer s.mu.RUnlock()
580 for _, m := range s.Messages {
581 if m.Role != provider.RoleSystem {
582 return true
583 }
584 }
585 return false
586 }
587
588 // HasSystemMessage reports whether the session starts with a system message,
589 // which carries the agent's stable identity and behavioural contract. Sessions
590 // without one are not safe to persist: when reloaded the model has no identity
591 // context and falls back to its training-data defaults.
592 func (s *Session) HasSystemMessage() bool {
593 s.mu.RLock()
594 defer s.mu.RUnlock()
595 return len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem
596 }
597
597 lines GO