| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "slices" |
| 6 | |
| 7 | "reasonix/internal/event" |
| 8 | "reasonix/internal/provider" |
| 9 | "reasonix/internal/sessioncontext" |
| 10 | ) |
| 11 | |
| 12 | // TurnContextBundle carries role-specific runtime snapshots without changing |
| 13 | // Runner.Run. BootstrapOnly lets synthetic continuations repair an upgraded |
| 14 | // legacy session that has no snapshot, while never consuming later updates. |
| 15 | type TurnContextBundle struct { |
| 16 | Executor sessioncontext.Snapshot |
| 17 | Planner sessioncontext.Snapshot |
| 18 | BootstrapOnly bool |
| 19 | } |
| 20 | |
| 21 | type turnContextBundleKey struct{} |
| 22 | type turnContextRoleKey struct{} |
| 23 | |
| 24 | type turnContextRole uint8 |
| 25 | |
| 26 | const ( |
| 27 | turnContextExecutor turnContextRole = iota |
| 28 | turnContextPlanner |
| 29 | ) |
| 30 | |
| 31 | type turnContextDiagnostics struct { |
| 32 | snapshot sessioncontext.Snapshot |
| 33 | stats sessioncontext.Diagnostics |
| 34 | target string |
| 35 | reasons []string |
| 36 | } |
| 37 | |
| 38 | // WithTurnContextBundle attaches provider-visible runtime context to one host |
| 39 | // turn. Empty bundles preserve the old context identity and request fast path. |
| 40 | func WithTurnContextBundle(ctx context.Context, bundle TurnContextBundle) context.Context { |
| 41 | if ctx == nil { |
| 42 | ctx = context.Background() |
| 43 | } |
| 44 | if bundle.Executor.Content == "" && bundle.Planner.Content == "" { |
| 45 | return ctx |
| 46 | } |
| 47 | return context.WithValue(ctx, turnContextBundleKey{}, bundle) |
| 48 | } |
| 49 | |
| 50 | // WithoutTurnContextBundle prevents a child agent from inheriting the parent's |
| 51 | // provider-visible runtime snapshot through context.Context. Child sessions |
| 52 | // receive only the explicit task/workspace context assembled by their caller; |
| 53 | // fork/continue transcript inheritance remains an explicit session operation. |
| 54 | func WithoutTurnContextBundle(ctx context.Context) context.Context { |
| 55 | if ctx == nil { |
| 56 | ctx = context.Background() |
| 57 | } |
| 58 | return context.WithValue(ctx, turnContextBundleKey{}, struct{}{}) |
| 59 | } |
| 60 | |
| 61 | func (a *Agent) prepareProviderTurn(ctx context.Context, input string) string { |
| 62 | return a.withTurnPreferences(input) |
| 63 | } |
| 64 | |
| 65 | func withPlannerTurnContext(ctx context.Context) context.Context { |
| 66 | if ctx == nil { |
| 67 | ctx = context.Background() |
| 68 | } |
| 69 | return context.WithValue(ctx, turnContextRoleKey{}, turnContextPlanner) |
| 70 | } |
| 71 | |
| 72 | func turnContextFromContext(ctx context.Context) (sessioncontext.Snapshot, bool, turnContextRole) { |
| 73 | if ctx == nil { |
| 74 | return sessioncontext.Snapshot{}, false, turnContextExecutor |
| 75 | } |
| 76 | bundle, ok := ctx.Value(turnContextBundleKey{}).(TurnContextBundle) |
| 77 | if !ok { |
| 78 | return sessioncontext.Snapshot{}, false, turnContextExecutor |
| 79 | } |
| 80 | role, _ := ctx.Value(turnContextRoleKey{}).(turnContextRole) |
| 81 | snapshot := bundle.Executor |
| 82 | if role == turnContextPlanner { |
| 83 | snapshot = bundle.Planner |
| 84 | } |
| 85 | if snapshot.Content == "" { |
| 86 | return sessioncontext.Snapshot{}, false, role |
| 87 | } |
| 88 | parsed, valid := sessioncontext.Parse(snapshot.Content) |
| 89 | if !valid || parsed.Digest != snapshot.Digest { |
| 90 | return sessioncontext.Snapshot{}, false, role |
| 91 | } |
| 92 | return parsed, bundle.BootstrapOnly, role |
| 93 | } |
| 94 | |
| 95 | // AppendTurnContext appends the role-appropriate snapshot exactly once per |
| 96 | // digest. It derives state from model-visible history so resume, rewind, fork, |
| 97 | // and compaction need no persisted sidecar field. |
| 98 | func (a *Agent) AppendTurnContext(ctx context.Context) bool { |
| 99 | return a.appendTurnContextAndMessages(ctx) |
| 100 | } |
| 101 | |
| 102 | // AppendTurnContextAndUser appends a role-appropriate snapshot and the real |
| 103 | // user message in one Session.AddBatch. This keeps mid-turn autosave from |
| 104 | // persisting a context-only admission boundary. |
| 105 | func (a *Agent) AppendTurnContextAndUser(ctx context.Context, user provider.Message) bool { |
| 106 | appended, _ := a.AppendTurnContextAndUserChecked(ctx, user) |
| 107 | return appended |
| 108 | } |
| 109 | |
| 110 | // AppendTurnContextAndUserChecked is the controller-facing admission path. It |
| 111 | // reports event-store failures before the legacy transcript is changed. |
| 112 | func (a *Agent) AppendTurnContextAndUserChecked(ctx context.Context, user provider.Message) (bool, error) { |
| 113 | if a == nil || a.sess.session() == nil { |
| 114 | return false, nil |
| 115 | } |
| 116 | if user.ID == "" { |
| 117 | user.ID = turnUserMessageID(ctx, a.sess.session()) |
| 118 | } |
| 119 | appendedContext, err := a.appendTurnContextAndMessagesChecked(ctx, user) |
| 120 | if err != nil { |
| 121 | return false, err |
| 122 | } |
| 123 | emitAdmittedUserMessage(a.svc.sink, user) |
| 124 | return appendedContext, nil |
| 125 | } |
| 126 | |
| 127 | func (a *Agent) appendTurnContextAndMessages(ctx context.Context, messages ...provider.Message) bool { |
| 128 | appended, _ := a.appendTurnContextAndMessagesChecked(ctx, messages...) |
| 129 | return appended |
| 130 | } |
| 131 | |
| 132 | func (a *Agent) appendTurnContextAndMessagesChecked(ctx context.Context, messages ...provider.Message) (bool, error) { |
| 133 | if a == nil { |
| 134 | return false, nil |
| 135 | } |
| 136 | sess := a.sess.session() |
| 137 | if sess == nil { |
| 138 | return false, nil |
| 139 | } |
| 140 | contextMessage, appendContext := a.prepareTurnContext(ctx) |
| 141 | if !appendContext && len(messages) == 0 { |
| 142 | return false, nil |
| 143 | } |
| 144 | batch := make([]provider.Message, 0, len(messages)+1) |
| 145 | if appendContext { |
| 146 | batch = append(batch, contextMessage) |
| 147 | } |
| 148 | batch = append(batch, messages...) |
| 149 | if err := a.appendCommittedMessages(ctx, "turn-context-and-user", batch...); err != nil { |
| 150 | return false, err |
| 151 | } |
| 152 | return appendContext, nil |
| 153 | } |
| 154 | |
| 155 | func (a *Agent) prepareTurnContext(ctx context.Context) (provider.Message, bool) { |
| 156 | if a == nil { |
| 157 | return provider.Message{}, false |
| 158 | } |
| 159 | sess := a.sess.session() |
| 160 | if sess == nil { |
| 161 | return provider.Message{}, false |
| 162 | } |
| 163 | snapshot, bootstrapOnly, role := turnContextFromContext(ctx) |
| 164 | if snapshot.Content == "" { |
| 165 | return provider.Message{}, false |
| 166 | } |
| 167 | visible := a.modelVisibleMessages() |
| 168 | previous, found := latestTurnContextSnapshot(visible) |
| 169 | a.turn.sessionContext = turnContextDiagnostics{ |
| 170 | snapshot: snapshot, |
| 171 | stats: sessioncontext.SectionDiagnostics(snapshot), |
| 172 | target: role.String(), |
| 173 | } |
| 174 | if found { |
| 175 | if bootstrapOnly || previous.Digest == snapshot.Digest { |
| 176 | return provider.Message{}, false |
| 177 | } |
| 178 | a.turn.sessionContext.reasons = changedTurnContextReasons(previous, snapshot) |
| 179 | } else { |
| 180 | reason := "first_seen" |
| 181 | if bootstrapOnly || hasPriorConversation(visible) { |
| 182 | reason = "legacy_resume" |
| 183 | } |
| 184 | a.turn.sessionContext.reasons = []string{reason} |
| 185 | } |
| 186 | return HostGeneratedUserMessage(snapshot.Content), true |
| 187 | } |
| 188 | |
| 189 | func latestTurnContextSnapshot(messages []provider.Message) (sessioncontext.Snapshot, bool) { |
| 190 | for i := range slices.Backward(messages) { |
| 191 | message := messages[i] |
| 192 | if message.Role != provider.RoleUser || message.Origin != provider.MessageOriginHost { |
| 193 | continue |
| 194 | } |
| 195 | if snapshot, ok := sessioncontext.Parse(message.Content); ok { |
| 196 | return snapshot, true |
| 197 | } |
| 198 | } |
| 199 | return sessioncontext.Snapshot{}, false |
| 200 | } |
| 201 | |
| 202 | func isSessionContextMessage(message provider.Message) bool { |
| 203 | if message.Role != provider.RoleUser || message.Origin != provider.MessageOriginHost { |
| 204 | return false |
| 205 | } |
| 206 | return sessioncontext.IsContent(message.Content) |
| 207 | } |
| 208 | |
| 209 | func (r turnContextRole) String() string { |
| 210 | if r == turnContextPlanner { |
| 211 | return "planner" |
| 212 | } |
| 213 | return "executor" |
| 214 | } |
| 215 | |
| 216 | func hasPriorConversation(messages []provider.Message) bool { |
| 217 | for _, message := range messages { |
| 218 | if message.Role != provider.RoleSystem && !message.LocalOnly { |
| 219 | return true |
| 220 | } |
| 221 | } |
| 222 | return false |
| 223 | } |
| 224 | |
| 225 | func changedTurnContextReasons(previous, current sessioncontext.Snapshot) []string { |
| 226 | var reasons []string |
| 227 | if previous.Sections.Environment != current.Sections.Environment || previous.Sections.Workspace != current.Sections.Workspace { |
| 228 | reasons = append(reasons, "runtime_changed") |
| 229 | } |
| 230 | if previous.Sections.BackgroundMemory != current.Sections.BackgroundMemory { |
| 231 | reasons = append(reasons, "memory_changed") |
| 232 | } |
| 233 | if previous.Sections.SkillsCatalog != current.Sections.SkillsCatalog { |
| 234 | reasons = append(reasons, "skills_changed") |
| 235 | } |
| 236 | if len(reasons) == 0 { |
| 237 | reasons = append(reasons, "snapshot_changed") |
| 238 | } |
| 239 | return reasons |
| 240 | } |
| 241 | |
| 242 | func (a *Agent) attachSessionContextDiagnostics(diagnostics *CacheDiagnostics) { |
| 243 | if a == nil || diagnostics == nil || a.turn.sessionContext.snapshot.Content == "" { |
| 244 | return |
| 245 | } |
| 246 | diagnostics.SessionContext = eventSessionContextDiagnostics(a.turn.sessionContext) |
| 247 | } |
| 248 | |
| 249 | func eventSessionContextDiagnostics(observed turnContextDiagnostics) *event.SessionContextDiagnostics { |
| 250 | if observed.snapshot.Content == "" { |
| 251 | return nil |
| 252 | } |
| 253 | section := func(stat sessioncontext.SectionStat) event.SessionContextSectionDiagnostics { |
| 254 | return event.SessionContextSectionDiagnostics{Digest: stat.Digest, Chars: stat.Chars} |
| 255 | } |
| 256 | return &event.SessionContextDiagnostics{ |
| 257 | Version: observed.snapshot.Version, Digest: observed.snapshot.Digest, |
| 258 | TargetRole: observed.target, Reasons: append([]string(nil), observed.reasons...), |
| 259 | Environment: section(observed.stats.Environment), Workspace: section(observed.stats.Workspace), |
| 260 | BackgroundMemory: section(observed.stats.BackgroundMemory), SkillsCatalog: section(observed.stats.SkillsCatalog), |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func captureTurnContextShape(system string, schemas []provider.ToolSchema, rewriteVersion int, messages []provider.Message) PrefixShape { |
| 265 | shape := CaptureShape(system, schemas, rewriteVersion) |
| 266 | if snapshot, ok := latestTurnContextSnapshot(messages); ok { |
| 267 | shape.SessionContextDigest = snapshot.Digest |
| 268 | shape.PrefixHash = shortHash(map[string]string{ |
| 269 | "stable": shape.PrefixHash, "session_context": snapshot.Digest, |
| 270 | }) |
| 271 | } |
| 272 | return shape |
| 273 | } |
| 274 |