返回 DeepSeek-Reasonix
session_checkpoint.go
根目录 / internal / agent / session_checkpoint.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7
8 "reasonix/internal/provider"
9 )
10
11 // SessionCheckpointBoundary identifies a semantic durability barrier. A
12 // checkpoint is intentionally absent from ordinary message, todo, approval,
13 // and terminal appends; those use the session's write-behind policy.
14 type SessionCheckpointBoundary string
15
16 const (
17 CheckpointBeforeModel SessionCheckpointBoundary = "before_model"
18 CheckpointBeforeTopTool SessionCheckpointBoundary = "before_top_level_tool"
19 )
20
21 // SessionCheckpointer makes the side-effect boundary explicit without coupling
22 // the agent loop to a concrete session backend.
23 type SessionCheckpointer interface {
24 CheckpointSession(context.Context, SessionCheckpointBoundary) error
25 }
26
27 // SessionEventRecorder accepts exact, already-formed provider messages at the
28 // point the agent commits them. It avoids reconstructing the authoritative
29 // event log later by comparing mutable conversation snapshots.
30 type SessionEventRecorder interface {
31 RecordSessionMessages(context.Context, string, []provider.Message) error
32 }
33
34 // SessionMessageMutationRecorder records an explicit mutation of one stable
35 // transcript message. Local recovery/authorization metadata often changes an
36 // existing message without changing provider-visible bytes; those mutations
37 // still need a typed event and must not be rediscovered later by diffing the
38 // mutable Session.Messages slice.
39 type SessionMessageMutationRecorder interface {
40 RecordSessionMessageUpsert(context.Context, string, provider.Message) error
41 }
42
43 // SessionModelContextCommit is an exact provider-visible projection produced by
44 // one context-maintenance transaction. OperationID must be stable across
45 // retries so the session log can deduplicate an accepted commit.
46 type SessionModelContextCommit struct {
47 OperationID string
48 Reason string
49 Messages []provider.Message
50 }
51
52 // SessionModelContextCommitResult distinguishes a rejection before the event
53 // log accepted the projection from a durability failure after acceptance. Once
54 // accepted, the Agent must retain the matching in-memory projection even when
55 // the durability wait returns an error.
56 type SessionModelContextCommitResult struct {
57 Accepted bool
58 Durable bool
59 }
60
61 // SessionModelContextRecorder durably records the exact context that the next
62 // provider request would receive. It must not call back into the Agent.
63 type SessionModelContextRecorder interface {
64 RecordSessionModelContext(context.Context, SessionModelContextCommit) (SessionModelContextCommitResult, error)
65 }
66
67 func (a *Agent) SetSessionCheckpointer(checkpointer SessionCheckpointer) {
68 if a != nil {
69 a.svc.sessionCheckpointer = checkpointer
70 }
71 }
72
73 func (a *Agent) checkpointSession(ctx context.Context, boundary SessionCheckpointBoundary) error {
74 if boundary == CheckpointBeforeModel {
75 if err := a.confirmPendingModelContext(ctx); err != nil {
76 return err
77 }
78 }
79 if a == nil || a.svc.sessionCheckpointer == nil {
80 return ctx.Err()
81 }
82 if err := a.svc.sessionCheckpointer.CheckpointSession(ctx, boundary); err != nil {
83 return err
84 }
85 return ctx.Err()
86 }
87
88 // confirmPendingModelContext completes an accepted context-maintenance commit
89 // before another model request can be prepared or dispatched. It deliberately
90 // reuses the frozen operation ID and payload retained by the original attempt.
91 func (a *Agent) confirmPendingModelContext(ctx context.Context) error {
92 if a == nil {
93 return ctx.Err()
94 }
95 a.sess.compactionMu.Lock()
96 pending := a.sess.pendingModelContextCommit
97 if pending == nil {
98 a.sess.compactionMu.Unlock()
99 return ctx.Err()
100 }
101 recorder, ok := a.svc.sessionCheckpointer.(SessionModelContextRecorder)
102 if !ok {
103 a.sess.compactionMu.Unlock()
104 return errors.New("confirm pending model context: recorder unavailable")
105 }
106 commit := cloneSessionModelContextCommit(*pending)
107 result, err := recorder.RecordSessionModelContext(ctx, commit)
108 if err != nil {
109 a.sess.compactionMu.Unlock()
110 return fmt.Errorf("confirm pending model context: %w", err)
111 }
112 if !result.Accepted || !result.Durable {
113 a.sess.compactionMu.Unlock()
114 return errors.New("confirm pending model context: commit is not durable")
115 }
116 if err := a.persistCompactionStateLocked(); err != nil {
117 a.sess.compactionMu.Unlock()
118 return fmt.Errorf("confirm pending model context sidecar: %w", err)
119 }
120 a.sess.pendingModelContextCommit = nil
121 a.sess.checkpointState = "applied"
122 var receipt *ContextMaintenanceReceipt
123 if a.sess.compactionState.LastReceipt != nil {
124 copy := *a.sess.compactionState.LastReceipt
125 receipt = &copy
126 }
127 a.sess.compactionMu.Unlock()
128 if receipt != nil {
129 a.emitContextMaintenance(receipt)
130 }
131 return ctx.Err()
132 }
133
134 func cloneSessionModelContextCommit(commit SessionModelContextCommit) SessionModelContextCommit {
135 commit.Messages = freezeProviderRequest(provider.Request{Messages: commit.Messages}).Messages
136 return commit
137 }
138
139 func (a *Agent) appendCommittedMessages(ctx context.Context, reason string, messages ...provider.Message) error {
140 if a == nil || len(messages) == 0 {
141 return nil
142 }
143 for i := range messages {
144 if messages[i].ID == "" {
145 messages[i].ID = NewMessageID()
146 }
147 }
148 if recorder, ok := a.svc.sessionCheckpointer.(SessionEventRecorder); ok {
149 if err := recorder.RecordSessionMessages(ctx, reason, messages); err != nil {
150 return err
151 }
152 }
153 a.sess.conversation.AddBatch(messages...)
154 return nil
155 }
156
157 // ModelHistorySnapshot returns the exact context projection that the next
158 // provider request would receive. Compaction recorders use it after an
159 // installed projection rather than deriving context from summary prose.
160 func (a *Agent) ModelHistorySnapshot() []provider.Message {
161 if a == nil {
162 return nil
163 }
164 return append([]provider.Message(nil), a.modelVisibleMessages()...)
165 }
166
166 lines GO