返回 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 // persistFormat pins sessions loaded from the legacy checkpoint/schema-1
81 // family to that writer. Explicit migration owns format conversion;
82 // zero keeps ordinary new-session selection.
83 persistFormat sessionPersistFormat
84 head sessionHeadState
85 }
86
87 type sessionPersistFormat uint8
88
89 const (
90 sessionPersistAuto sessionPersistFormat = iota
91 sessionPersistLegacy
92 sessionPersistDAG
93 )
94
95 // NewSession initializes a session with an optional system prompt.
96 func NewSession(system string) *Session {
97 s := &Session{}
98 if system != "" {
99 s.Messages = append(s.Messages, provider.Message{Role: provider.RoleSystem, Content: system, ID: NewMessageID()})
100 }
101 return s
102 }
103
104 // Add appends a message.
105 func (s *Session) Add(m provider.Message) {
106 s.mu.Lock()
107 defer s.mu.Unlock()
108 if m.ID == "" {
109 m.ID = NewMessageID()
110 }
111 s.expireProtocolRecoveryLocked([]provider.Message{m})
112 s.Messages = append(s.Messages, m)
113 s.version++
114 }
115
116 // AddBatch appends one logical transcript batch under a single lock. Turn
117 // admission uses it for an optional host context revision plus the real user
118 // message so autosave can never observe only half of the admitted boundary.
119 func (s *Session) AddBatch(messages ...provider.Message) {
120 if s == nil || len(messages) == 0 {
121 return
122 }
123 s.mu.Lock()
124 mintMessageIDs(messages)
125 s.expireProtocolRecoveryLocked(messages)
126 s.Messages = append(s.Messages, messages...)
127 s.version++
128 s.mu.Unlock()
129 }
130
131 // SetLeadingSystemPrompt updates or sets the leading system prompt message.
132 func (s *Session) SetLeadingSystemPrompt(prompt string) {
133 s.SetLeadingSystemPromptWithReason(prompt, "system_prompt_refresh")
134 }
135
136 // SetLeadingSystemPromptWithReason refreshes the authoritative system prompt
137 // and records the provider-visible rewrite boundary exactly once. It is used
138 // for low-frequency host prompt migrations, never ordinary pinned-context
139 // updates (which append user-role revisions instead).
140 func (s *Session) SetLeadingSystemPromptWithReason(prompt, reason string) bool {
141 if s == nil {
142 return false
143 }
144 s.mu.Lock()
145 defer s.mu.Unlock()
146 if len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem {
147 if s.Messages[0].Content == prompt {
148 return false
149 }
150 s.Messages[0].Content = prompt
151 } else if prompt != "" {
152 s.Messages = append([]provider.Message{{Role: provider.RoleSystem, Content: prompt, ID: NewMessageID()}}, s.Messages...)
153 } else {
154 return false
155 }
156 s.rewriteVersion++
157 if reason = strings.TrimSpace(reason); reason != "" {
158 s.pendingContentReasons = append(s.pendingContentReasons, reason)
159 }
160 s.version++
161 return true
162 }
163
164 // ConsumeFinalReadinessRecovery marks the newest pending readiness checkpoint
165 // consumed before any next user turn (explicit recovery or ordinary follow-up).
166 // This is local metadata only, so the rewrite does not alter provider bytes or
167 // prompt-cache identity.
168 func (s *Session) ConsumeFinalReadinessRecovery() bool {
169 if s == nil {
170 return false
171 }
172 s.mu.Lock()
173 defer s.mu.Unlock()
174 for i := range slices.Backward(s.Messages) {
175 message := &s.Messages[i]
176 if message.LocalOnly && message.FinalReadinessRecovery != nil && message.FinalReadinessRecovery.Pending {
177 consumed := *message.FinalReadinessRecovery
178 consumed.Pending = false
179 consumed.Missing = append([]string(nil), consumed.Missing...)
180 consumed.Checkpoint = append([]byte(nil), consumed.Checkpoint...)
181 message.FinalReadinessRecovery = &consumed
182 s.rewriteVersion++
183 s.version++
184 return true
185 }
186 if IsUserAuthoredTurnMessage(*message) {
187 return false
188 }
189 }
190 return false
191 }
192
193 // AddDecisionReceipt persists local decision metadata without inserting a
194 // standalone message into the current tool turn. Tool results must remain
195 // directly adjacent to the assistant message that requested them; otherwise
196 // session normalization fabricates interrupted placeholders and older readers
197 // can lose the real result. Attaching to the newest assistant message keeps the
198 // provider-visible transcript byte-for-byte equivalent after ModelMessages.
199 //
200 // The fallback sentinel covers host decisions made before any assistant message
201 // exists. Older readers already discard this unmatched tool record safely.
202 func (s *Session) AddDecisionReceipt(receipt *provider.DecisionReceipt) {
203 if s == nil || receipt == nil {
204 return
205 }
206 s.mu.Lock()
207 defer s.mu.Unlock()
208 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
209 for i := len(s.Messages) - 1; i >= 0; i-- {
210 if IsUserAuthoredTurnMessage(s.Messages[i]) {
211 break
212 }
213 if s.Messages[i].Role != provider.RoleAssistant || s.Messages[i].LocalOnly {
214 continue
215 }
216 receipts := append([]*provider.DecisionReceipt(nil), s.Messages[i].DecisionReceipts...)
217 s.Messages[i].DecisionReceipts = append(receipts, receipt)
218 // A mid-turn snapshot may already contain this assistant message. Force
219 // the next save to replace it instead of treating the later tool result
220 // as the only append-only change.
221 s.rewriteVersion++
222 s.version++
223 return
224 }
225 s.Messages = append(s.Messages, provider.Message{
226 Role: provider.RoleTool,
227 ToolCallID: provider.LocalOnlyToolID,
228 Name: provider.LocalOnlyToolName,
229 LocalOnly: true,
230 DecisionReceipt: receipt,
231 })
232 s.version++
233 }
234
235 // UpdateToolCallPreview replaces the preview fields of the newest matching
236 // assistant tool call. A dependent writer can only be previewed after an
237 // earlier writer in the same model batch succeeds; updating under the session
238 // lock keeps live History/Snapshot readers race-free and ensures the refreshed
239 // preview is what a resumed session archives.
240 func (s *Session) UpdateToolCallPreview(call provider.ToolCall) bool {
241 if call.ID == "" {
242 return false
243 }
244 s.mu.Lock()
245 defer s.mu.Unlock()
246 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
247 for i := len(s.Messages) - 1; i >= 0; i-- {
248 if s.Messages[i].Role != provider.RoleAssistant {
249 continue
250 }
251 calls := s.Messages[i].ToolCalls
252 for j := range calls {
253 if calls[j].ID != call.ID {
254 continue
255 }
256 cloned := append([]provider.ToolCall(nil), calls...)
257 cloned[j].Diff = call.Diff
258 cloned[j].Added = call.Added
259 cloned[j].Removed = call.Removed
260 s.Messages[i].ToolCalls = cloned
261 // A snapshot may have persisted the original assistant message while
262 // its tools were still running. Mark this as a rewrite so a later
263 // autosave replaces that message instead of misclassifying the tool
264 // results as an append-only suffix.
265 s.rewriteVersion++
266 s.version++
267 return true
268 }
269 }
270 return false
271 }
272
273 // UpdateToolCallResolution persists the host-resolved target metadata for the
274 // newest matching stable proxy call. The model-visible Name/Arguments remain
275 // unchanged; this metadata exists only so live and reloaded frontends classify
276 // MCP readers and writers accurately.
277 func (s *Session) UpdateToolCallResolution(call provider.ToolCall) bool {
278 if call.ID == "" || call.ResolvedReadOnly == nil {
279 return false
280 }
281 s.mu.Lock()
282 defer s.mu.Unlock()
283 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
284 for i := len(s.Messages) - 1; i >= 0; i-- {
285 if s.Messages[i].Role != provider.RoleAssistant {
286 continue
287 }
288 calls := s.Messages[i].ToolCalls
289 for j := range calls {
290 if calls[j].ID != call.ID {
291 continue
292 }
293 cloned := append([]provider.ToolCall(nil), calls...)
294 readOnly := *call.ResolvedReadOnly
295 cloned[j].ResolvedName = call.ResolvedName
296 cloned[j].CapabilityID = call.CapabilityID
297 cloned[j].ResolvedReadOnly = &readOnly
298 s.Messages[i].ToolCalls = cloned
299 // A mid-turn snapshot may already contain the unresolved proxy call.
300 // Force the next save to rewrite that assistant message with its
301 // resolved local metadata.
302 s.rewriteVersion++
303 s.version++
304 return true
305 }
306 }
307 return false
308 }
309
310 // Replace swaps the whole message log without classifying the change as a
311 // persisted-history rewrite. Call Rewrite when a live session changes messages
312 // that a mid-turn snapshot may already have written.
313 func (s *Session) Replace(msgs []provider.Message) {
314 s.mu.Lock()
315 defer s.mu.Unlock()
316 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
317 mintMessageIDs(msgs)
318 s.Messages = msgs
319 s.version++
320 }
321
322 // Rewrite atomically replaces the message log and marks it as a rewrite. The
323 // atomic classification matters when a periodic snapshot races compaction,
324 // pruning, or local metadata edits: a later autosave must use owned-rewrite
325 // conflict checks instead of mistaking the modified prefix for another writer.
326 //
327 // reason names the provider-visible change (e.g. "rewind_truncate",
328 // "guardian_merge") and is queued for the next DrainContentRewriteReasons
329 // call, which feeds cache-diagnostics attribution. Callers whose msgs only
330 // change local-only display metadata (never serialized to the provider) must
331 // use ReplaceLocalMetadata instead, so they don't misreport a cache-prefix
332 // change that never happened.
333 func (s *Session) Rewrite(msgs []provider.Message, reason string) {
334 s.mu.Lock()
335 defer s.mu.Unlock()
336 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
337 mintMessageIDs(msgs)
338 s.Messages = msgs
339 s.rewriteVersion++
340 s.version++
341 if reason != "" {
342 s.pendingContentReasons = append(s.pendingContentReasons, reason)
343 }
344 }
345
346 // ReplaceLocalMetadata atomically replaces the message log exactly like
347 // Rewrite (including the rewriteVersion bump that forces the next save to use
348 // owned-rewrite conflict checks), for callers that only changed local-only
349 // display metadata (e.g. marking a resubmitted message Edited) rather than any
350 // provider-visible byte. Unlike Rewrite, it never queues a cache-prefix-change
351 // reason, since ModelMessages strips or never serializes what changed.
352 func (s *Session) ReplaceLocalMetadata(msgs []provider.Message) {
353 s.mu.Lock()
354 defer s.mu.Unlock()
355 msgs = retainUnresolvedToolRecords(s.Messages, msgs)
356 mintMessageIDs(msgs)
357 s.Messages = msgs
358 s.rewriteVersion++
359 s.version++
360 }
361
362 // DrainContentRewriteReasons returns and clears the reasons queued by Rewrite
363 // since the last drain. Called once per provider request (run_loop.go) so
364 // CompareShape can attribute a cache-prefix change to the operation that
365 // actually caused it.
366 func (s *Session) DrainContentRewriteReasons() []string {
367 s.mu.Lock()
368 defer s.mu.Unlock()
369 reasons := s.pendingContentReasons
370 s.pendingContentReasons = nil
371 return reasons
372 }
373
374 // NoteContentRewrite queues a provider-visible prefix-change reason without
375 // mutating Messages. Projection installs and resume-time system migrations use
376 // this so cache diagnostics attribute the next request's miss while the
377 // canonical transcript and its persistence baseline stay intact.
378 func (s *Session) NoteContentRewrite(reason string) {
379 if s == nil || reason == "" {
380 return
381 }
382 s.mu.Lock()
383 defer s.mu.Unlock()
384 s.pendingContentReasons = append(s.pendingContentReasons, reason)
385 }
386
387 // Snapshot returns a copy of the messages, safe to read from another goroutine
388 // while a turn appends. Frontends (History, Save) use it instead of touching the
389 // live slice.
390 func (s *Session) Snapshot() []provider.Message {
391 msgs, _, _ := s.snapshotWithVersion()
392 return msgs
393 }
394
395 // DisplayBaseline captures messages and their rewrite/head identity together.
396 // The controller calls this at an idle/admission boundary, before any new
397 // streaming event can commit to its display projection.
398 func (s *Session) DisplayBaseline() (messages []provider.Message, headID string, rewriteEpoch uint64) {
399 s.mu.RLock()
400 defer s.mu.RUnlock()
401 return append([]provider.Message(nil), s.Messages...), s.head.ref.HeadID, uint64(s.rewriteVersion)
402 }
403
404 // Len returns the number of messages, safe to call from any goroutine.
405 func (s *Session) Len() int {
406 s.mu.RLock()
407 defer s.mu.RUnlock()
408 return len(s.Messages)
409 }
410
411 // MessageRange returns a copy of the messages in [start, end), clamped to the
412 // current log bounds, safe to read from another goroutine while a turn
413 // appends. Paging frontends use it to fetch a display window without paying
414 // for a Snapshot of the whole history.
415 func (s *Session) MessageRange(start, end int) []provider.Message {
416 s.mu.RLock()
417 defer s.mu.RUnlock()
418 if start < 0 {
419 start = 0
420 }
421 if end > len(s.Messages) {
422 end = len(s.Messages)
423 }
424 if start >= end {
425 return []provider.Message{}
426 }
427 return append([]provider.Message(nil), s.Messages[start:end]...)
428 }
429
430 // CloneWithMessages returns a fresh Session carrying msgs while preserving the
431 // persistence baseline of the source session. Resume paths use this when they
432 // need to adjust loaded history before a rewrite; dropping persisted would make
433 // CAS treat the first legitimate rewrite as a stale-runtime conflict.
434 //
435 // Callers that are handed history from outside this Session should prefer
436 // CloneWithMessagesIfCompatible, so stale carried history cannot borrow a newer
437 // on-disk baseline.
438 func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
439 if s == nil {
440 return nil
441 }
442 s.mu.RLock()
443 defer s.mu.RUnlock()
444 version := s.version
445 if !messagesEqualForStorageList(s.Messages, msgs) {
446 version++
447 }
448 return &Session{
449 Messages: append([]provider.Message(nil), msgs...),
450 version: version,
451 recoveryMetadataVersion: s.recoveryMetadataVersion,
452 rewriteVersion: s.rewriteVersion,
453 persistedRewriteVersion: s.persistedRewriteVersion,
454 persisted: s.persisted,
455 normalizedDirty: s.normalizedDirty,
456 eventLogDamaged: s.eventLogDamaged,
457 rawMessages: append([]provider.Message(nil), s.rawMessages...),
458 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
459 persistFormat: s.persistFormat,
460 head: s.head.clone(),
461 }
462 }
463
464 // CloneWithMessagesIfCompatible preserves the persistence baseline only when
465 // msgs is the same persisted history, optionally with a refreshed leading system
466 // prompt. Other history changes must happen after Resume so SaveRewrite can
467 // still detect genuine stale-controller conflicts.
468 func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Session, bool) {
469 if s == nil {
470 return nil, false
471 }
472 s.mu.RLock()
473 defer s.mu.RUnlock()
474 if !messagesCompatibleForStorageBaseline(s.Messages, msgs) {
475 return nil, false
476 }
477 version := s.version
478 if !messagesEqualForStorageList(s.Messages, msgs) {
479 version++
480 }
481 return &Session{
482 Messages: append([]provider.Message(nil), msgs...),
483 version: version,
484 recoveryMetadataVersion: s.recoveryMetadataVersion,
485 rewriteVersion: s.rewriteVersion,
486 persistedRewriteVersion: s.persistedRewriteVersion,
487 persisted: s.persisted,
488 normalizedDirty: s.normalizedDirty,
489 eventLogDamaged: s.eventLogDamaged,
490 rawMessages: append([]provider.Message(nil), s.rawMessages...),
491 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
492 persistFormat: s.persistFormat,
493 head: s.head.clone(),
494 }, true
495 }
496
497 // projectionValidationMessages returns the current canonical transcript and,
498 // when LoadSession repaired it, the exact pre-repair disk view. Resume wrappers
499 // preserve both so projection sidecars can be migrated without weakening the
500 // covered-prefix check.
501 func (s *Session) projectionValidationMessages() (current, preRepair []provider.Message) {
502 if s == nil {
503 return nil, nil
504 }
505 s.mu.RLock()
506 defer s.mu.RUnlock()
507 current = append([]provider.Message(nil), s.Messages...)
508 if s.normalizedDirty && len(s.rawMessages) > 0 {
509 preRepair = append([]provider.Message(nil), s.rawMessages...)
510 }
511 return current, preRepair
512 }
513
514 // snapshotWithVersion returns the messages together with the version and
515 // rewriteVersion they were captured under, in one lock window: save paths
516 // persist exactly this rewriteVersion as the new baseline, so a rewrite that
517 // lands after the capture cannot be misrecorded as saved.
518 func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
519 s.mu.RLock()
520 defer s.mu.RUnlock()
521 return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
522 }
523
524 // snapshotMessagesVersion returns a copy of the messages with the transcript
525 // version, for projection validity checks that do not need rewriteVersion.
526 func (s *Session) snapshotMessagesVersion() ([]provider.Message, uint64) {
527 msgs, version, _ := s.snapshotWithVersion()
528 return msgs, version
529 }
530
531 // TranscriptVersion returns the current append/rewrite counter used by
532 // context-projection validity checks.
533 func (s *Session) TranscriptVersion() uint64 {
534 s.mu.RLock()
535 defer s.mu.RUnlock()
536 return s.version
537 }
538
539 // RewriteVersion returns the current rewrite version.
540 func (s *Session) RewriteVersion() int {
541 s.mu.RLock()
542 defer s.mu.RUnlock()
543 return s.rewriteVersion
544 }
545
546 // NeedsRewriteSave reports whether the message log was rewritten in place —
547 // rather than appended to — since the last successful full save of this
548 // session. Snapshot paths use it to decide that the next write must be an
549 // owned rewrite instead of an append.
550 func (s *Session) NeedsRewriteSave() bool {
551 s.mu.RLock()
552 defer s.mu.RUnlock()
553 return s.rewriteVersion > s.persistedRewriteVersion || s.recoveryMetadataVersion > s.persisted.version
554 }
555
556 // HasUnsavedChanges reports whether the in-memory transcript contains storage
557 // changes that have not been durably recorded at path. It is intentionally
558 // conservative when no verified baseline exists: an idle controller must not
559 // replace an in-memory conversation with a possibly older disk copy after a
560 // bounded lock failure or an interrupted save.
561 func (s *Session) HasUnsavedChanges(path string) bool {
562 if s == nil || strings.TrimSpace(path) == "" {
563 return false
564 }
565 msgs, _, rewriteVersion := s.snapshotWithVersion()
566 digest, err := digestSessionMessages(msgs)
567 if err != nil {
568 return true
569 }
570 key := canonicalSessionSavePath(path)
571 s.mu.RLock()
572 defer s.mu.RUnlock()
573 if !s.persisted.ok || s.persisted.path != key {
574 return true
575 }
576 if s.normalizedDirty || s.eventLogDamaged || rewriteVersion > s.persistedRewriteVersion {
577 return true
578 }
579 return !bytes.Equal(digest[:], s.persisted.digest[:])
580 }
581
582 // IncrementRewrite bumps the rewrite version by 1.
583 func (s *Session) IncrementRewrite() {
584 s.mu.Lock()
585 defer s.mu.Unlock()
586 s.rewriteVersion++
587 s.version++
588 }
589
590 // HasContent returns true when the session carries at least one user,
591 // assistant, or tool message — i.e. more than just a system prompt. An
592 // "empty" conversation that has never been used should not be persisted.
593 func (s *Session) HasContent() bool {
594 s.mu.RLock()
595 defer s.mu.RUnlock()
596 for _, m := range s.Messages {
597 if m.Role != provider.RoleSystem {
598 return true
599 }
600 }
601 return false
602 }
603
604 // HasSystemMessage reports whether the session starts with a system message,
605 // which carries the agent's stable identity and behavioural contract. Sessions
606 // without one are not safe to persist: when reloaded the model has no identity
607 // context and falls back to its training-data defaults.
608 func (s *Session) HasSystemMessage() bool {
609 s.mu.RLock()
610 defer s.mu.RUnlock()
611 return len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem
612 }
613
613 lines GO