返回 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 "sync"
7
8 "reasonix/internal/provider"
9 )
10
11 // Session holds the conversation history for one task. The run loop (one turn at
12 // a time) is the only writer, but a frontend can read History/Save from another
13 // goroutine while a turn appends, so mu guards Messages. Direct Messages reads on
14 // the run-loop goroutine stay lock-free (serial with its own writes); cross-
15 // goroutine access goes through Snapshot.
16 type Session struct {
17 mu sync.RWMutex
18 Messages []provider.Message
19 version uint64
20 rewriteVersion int // bumped each time the log is rewritten (compact/fold)
21 // persistedRewriteVersion is the highest rewriteVersion whose transcript
22 // has fully reached disk. It lives on the Session — not on the controller
23 // — so swapping session objects can never orphan or misattribute the
24 // baseline: NeedsRewriteSave always compares a session against its own
25 // save history. Save paths advance it under s.mu with the rewriteVersion
26 // captured alongside the message snapshot, never a re-read one, so a
27 // compaction landing mid-save stays unpersisted.
28 persistedRewriteVersion int
29 persisted sessionPersistState
30 // normalizedDirty is set when LoadSession repaired the history on the way in
31 // (empty tool-call names, dangling calls, truncated args, …). The repair
32 // already lives in Messages, so the next Save persists it automatically as
33 // part of the usual full rewrite; the flag exists for observability and to
34 // let callers opt out of work that a dirty session would make redundant.
35 normalizedDirty bool
36 // eventLogDamaged is set when LoadSession found the on-disk event log torn
37 // or corrupt and returned the replayable prefix (or the .jsonl checkpoint).
38 // The next save heals the log with a rewrite-and-compact.
39 eventLogDamaged bool
40 // rawMessages preserves the pre-normalization transcript when the load-time
41 // repairs changed it (normalizedDirty). It is only meaningful on a freshly
42 // loaded Session: checkSnapshotWrite compares a pending snapshot against
43 // what is actually on disk, and the repaired view no longer represents
44 // those bytes — a session that kept running extends the raw transcript.
45 rawMessages []provider.Message
46 // pendingContentReasons accumulates a reason string each time Rewrite()
47 // actually replaces provider-visible message bytes (compact, prune/snip,
48 // summarize, rewind, guardian merge). ReplaceLocalMetadata bumps
49 // rewriteVersion for the same save-path (NeedsRewriteSave) purpose without
50 // appending here, because ModelMessages strips or never serializes the
51 // local-only metadata it changes — so that path must never report a
52 // cache-prefix change. DrainContentRewriteReasons (run_loop.go, once per
53 // provider request) is the sole consumer.
54 pendingContentReasons []string
55 }
56
57 // NewSession initializes a session with an optional system prompt.
58 func NewSession(system string) *Session {
59 s := &Session{}
60 if system != "" {
61 s.Messages = append(s.Messages, provider.Message{Role: provider.RoleSystem, Content: system})
62 }
63 return s
64 }
65
66 // Add appends a message.
67 func (s *Session) Add(m provider.Message) {
68 s.mu.Lock()
69 defer s.mu.Unlock()
70 s.Messages = append(s.Messages, m)
71 s.version++
72 }
73
74 // AddDecisionReceipt persists local decision metadata without inserting a
75 // standalone message into the current tool turn. Tool results must remain
76 // directly adjacent to the assistant message that requested them; otherwise
77 // session normalization fabricates interrupted placeholders and older readers
78 // can lose the real result. Attaching to the newest assistant message keeps the
79 // provider-visible transcript byte-for-byte equivalent after ModelMessages.
80 //
81 // The fallback sentinel covers host decisions made before any assistant message
82 // exists. Older readers already discard this unmatched tool record safely.
83 func (s *Session) AddDecisionReceipt(receipt *provider.DecisionReceipt) {
84 if s == nil || receipt == nil {
85 return
86 }
87 s.mu.Lock()
88 defer s.mu.Unlock()
89 for i := len(s.Messages) - 1; i >= 0; i-- {
90 if s.Messages[i].Role == provider.RoleUser && !s.Messages[i].LocalOnly {
91 break
92 }
93 if s.Messages[i].Role != provider.RoleAssistant || s.Messages[i].LocalOnly {
94 continue
95 }
96 receipts := append([]*provider.DecisionReceipt(nil), s.Messages[i].DecisionReceipts...)
97 s.Messages[i].DecisionReceipts = append(receipts, receipt)
98 // A mid-turn snapshot may already contain this assistant message. Force
99 // the next save to replace it instead of treating the later tool result
100 // as the only append-only change.
101 s.rewriteVersion++
102 s.version++
103 return
104 }
105 s.Messages = append(s.Messages, provider.Message{
106 Role: provider.RoleTool,
107 ToolCallID: provider.LocalOnlyToolID,
108 Name: provider.LocalOnlyToolName,
109 LocalOnly: true,
110 DecisionReceipt: receipt,
111 })
112 s.version++
113 }
114
115 // UpdateToolCallPreview replaces the preview fields of the newest matching
116 // assistant tool call. A dependent writer can only be previewed after an
117 // earlier writer in the same model batch succeeds; updating under the session
118 // lock keeps live History/Snapshot readers race-free and ensures the refreshed
119 // preview is what a resumed session archives.
120 func (s *Session) UpdateToolCallPreview(call provider.ToolCall) bool {
121 if call.ID == "" {
122 return false
123 }
124 s.mu.Lock()
125 defer s.mu.Unlock()
126 for i := len(s.Messages) - 1; i >= 0; i-- {
127 if s.Messages[i].Role != provider.RoleAssistant {
128 continue
129 }
130 calls := s.Messages[i].ToolCalls
131 for j := range calls {
132 if calls[j].ID != call.ID {
133 continue
134 }
135 cloned := append([]provider.ToolCall(nil), calls...)
136 cloned[j].Diff = call.Diff
137 cloned[j].Added = call.Added
138 cloned[j].Removed = call.Removed
139 s.Messages[i].ToolCalls = cloned
140 // A snapshot may have persisted the original assistant message while
141 // its tools were still running. Mark this as a rewrite so a later
142 // autosave replaces that message instead of misclassifying the tool
143 // results as an append-only suffix.
144 s.rewriteVersion++
145 s.version++
146 return true
147 }
148 }
149 return false
150 }
151
152 // UpdateToolCallResolution persists the host-resolved target metadata for the
153 // newest matching stable proxy call. The model-visible Name/Arguments remain
154 // unchanged; this metadata exists only so live and reloaded frontends classify
155 // MCP readers and writers accurately.
156 func (s *Session) UpdateToolCallResolution(call provider.ToolCall) bool {
157 if call.ID == "" || call.ResolvedReadOnly == nil {
158 return false
159 }
160 s.mu.Lock()
161 defer s.mu.Unlock()
162 for i := len(s.Messages) - 1; i >= 0; i-- {
163 if s.Messages[i].Role != provider.RoleAssistant {
164 continue
165 }
166 calls := s.Messages[i].ToolCalls
167 for j := range calls {
168 if calls[j].ID != call.ID {
169 continue
170 }
171 cloned := append([]provider.ToolCall(nil), calls...)
172 readOnly := *call.ResolvedReadOnly
173 cloned[j].ResolvedName = call.ResolvedName
174 cloned[j].CapabilityID = call.CapabilityID
175 cloned[j].ResolvedReadOnly = &readOnly
176 s.Messages[i].ToolCalls = cloned
177 // A mid-turn snapshot may already contain the unresolved proxy call.
178 // Force the next save to rewrite that assistant message with its
179 // resolved local metadata.
180 s.rewriteVersion++
181 s.version++
182 return true
183 }
184 }
185 return false
186 }
187
188 // Replace swaps the whole message log without classifying the change as a
189 // persisted-history rewrite. Call Rewrite when a live session changes messages
190 // that a mid-turn snapshot may already have written.
191 func (s *Session) Replace(msgs []provider.Message) {
192 s.mu.Lock()
193 defer s.mu.Unlock()
194 s.Messages = msgs
195 s.version++
196 }
197
198 // Rewrite atomically replaces the message log and marks it as a rewrite. The
199 // atomic classification matters when a periodic snapshot races compaction,
200 // pruning, or local metadata edits: a later autosave must use owned-rewrite
201 // conflict checks instead of mistaking the modified prefix for another writer.
202 //
203 // reason names the provider-visible change (e.g. "compact_auto", "snip",
204 // "rewind_truncate") and is queued for the next DrainContentRewriteReasons
205 // call, which feeds cache-diagnostics attribution. Callers whose msgs only
206 // change local-only display metadata (never serialized to the provider) must
207 // use ReplaceLocalMetadata instead, so they don't misreport a cache-prefix
208 // change that never happened.
209 func (s *Session) Rewrite(msgs []provider.Message, reason string) {
210 s.mu.Lock()
211 defer s.mu.Unlock()
212 s.Messages = msgs
213 s.rewriteVersion++
214 s.version++
215 if reason != "" {
216 s.pendingContentReasons = append(s.pendingContentReasons, reason)
217 }
218 }
219
220 // ReplaceLocalMetadata atomically replaces the message log exactly like
221 // Rewrite (including the rewriteVersion bump that forces the next save to use
222 // owned-rewrite conflict checks), for callers that only changed local-only
223 // display metadata (e.g. marking a resubmitted message Edited) rather than any
224 // provider-visible byte. Unlike Rewrite, it never queues a cache-prefix-change
225 // reason, since ModelMessages strips or never serializes what changed.
226 func (s *Session) ReplaceLocalMetadata(msgs []provider.Message) {
227 s.mu.Lock()
228 defer s.mu.Unlock()
229 s.Messages = msgs
230 s.rewriteVersion++
231 s.version++
232 }
233
234 // DrainContentRewriteReasons returns and clears the reasons queued by Rewrite
235 // since the last drain. Called once per provider request (run_loop.go) so
236 // CompareShape can attribute a cache-prefix change to the operation that
237 // actually caused it.
238 func (s *Session) DrainContentRewriteReasons() []string {
239 s.mu.Lock()
240 defer s.mu.Unlock()
241 reasons := s.pendingContentReasons
242 s.pendingContentReasons = nil
243 return reasons
244 }
245
246 // Snapshot returns a copy of the messages, safe to read from another goroutine
247 // while a turn appends. Frontends (History, Save) use it instead of touching the
248 // live slice.
249 func (s *Session) Snapshot() []provider.Message {
250 msgs, _, _ := s.snapshotWithVersion()
251 return msgs
252 }
253
254 // Len returns the number of messages, safe to call from any goroutine.
255 func (s *Session) Len() int {
256 s.mu.RLock()
257 defer s.mu.RUnlock()
258 return len(s.Messages)
259 }
260
261 // CloneWithMessages returns a fresh Session carrying msgs while preserving the
262 // persistence baseline of the source session. Resume paths use this when they
263 // need to adjust loaded history before a rewrite; dropping persisted would make
264 // CAS treat the first legitimate rewrite as a stale-runtime conflict.
265 //
266 // Callers that are handed history from outside this Session should prefer
267 // CloneWithMessagesIfCompatible, so stale carried history cannot borrow a newer
268 // on-disk baseline.
269 func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
270 if s == nil {
271 return nil
272 }
273 s.mu.RLock()
274 defer s.mu.RUnlock()
275 version := s.version
276 if !messagesEqualForStorageList(s.Messages, msgs) {
277 version++
278 }
279 return &Session{
280 Messages: append([]provider.Message(nil), msgs...),
281 version: version,
282 rewriteVersion: s.rewriteVersion,
283 persistedRewriteVersion: s.persistedRewriteVersion,
284 persisted: s.persisted,
285 normalizedDirty: s.normalizedDirty,
286 eventLogDamaged: s.eventLogDamaged,
287 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
288 }
289 }
290
291 // CloneWithMessagesIfCompatible preserves the persistence baseline only when
292 // msgs is the same persisted history, optionally with a refreshed leading system
293 // prompt. Other history changes must happen after Resume so SaveRewrite can
294 // still detect genuine stale-controller conflicts.
295 func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Session, bool) {
296 if s == nil {
297 return nil, false
298 }
299 s.mu.RLock()
300 defer s.mu.RUnlock()
301 if !messagesCompatibleForStorageBaseline(s.Messages, msgs) {
302 return nil, false
303 }
304 version := s.version
305 if !messagesEqualForStorageList(s.Messages, msgs) {
306 version++
307 }
308 return &Session{
309 Messages: append([]provider.Message(nil), msgs...),
310 version: version,
311 rewriteVersion: s.rewriteVersion,
312 persistedRewriteVersion: s.persistedRewriteVersion,
313 persisted: s.persisted,
314 normalizedDirty: s.normalizedDirty,
315 eventLogDamaged: s.eventLogDamaged,
316 pendingContentReasons: append([]string(nil), s.pendingContentReasons...),
317 }, true
318 }
319
320 // snapshotWithVersion returns the messages together with the version and
321 // rewriteVersion they were captured under, in one lock window: save paths
322 // persist exactly this rewriteVersion as the new baseline, so a rewrite that
323 // lands after the capture cannot be misrecorded as saved.
324 func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
325 s.mu.RLock()
326 defer s.mu.RUnlock()
327 return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
328 }
329
330 // RewriteVersion returns the current rewrite version.
331 func (s *Session) RewriteVersion() int {
332 s.mu.RLock()
333 defer s.mu.RUnlock()
334 return s.rewriteVersion
335 }
336
337 // NeedsRewriteSave reports whether the history has been rewritten in memory
338 // (compaction, prune) since the last successful full save of this session.
339 // Snapshot paths use it to decide that the next write must be an owned
340 // rewrite instead of an append.
341 func (s *Session) NeedsRewriteSave() bool {
342 s.mu.RLock()
343 defer s.mu.RUnlock()
344 return s.rewriteVersion > s.persistedRewriteVersion
345 }
346
347 // IncrementRewrite bumps the rewrite version by 1.
348 func (s *Session) IncrementRewrite() {
349 s.mu.Lock()
350 defer s.mu.Unlock()
351 s.rewriteVersion++
352 s.version++
353 }
354
355 // HasContent returns true when the session carries at least one user,
356 // assistant, or tool message — i.e. more than just a system prompt. An
357 // "empty" conversation that has never been used should not be persisted.
358 func (s *Session) HasContent() bool {
359 s.mu.RLock()
360 defer s.mu.RUnlock()
361 for _, m := range s.Messages {
362 if m.Role != provider.RoleSystem {
363 return true
364 }
365 }
366 return false
367 }
368
369 // HasSystemMessage reports whether the session starts with a system message,
370 // which carries the agent's stable identity and behavioural contract. Sessions
371 // without one are not safe to persist: when reloaded the model has no identity
372 // context and falls back to its training-data defaults.
373 func (s *Session) HasSystemMessage() bool {
374 s.mu.RLock()
375 defer s.mu.RUnlock()
376 return len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem
377 }
378
378 lines GO