Files
2026-07-13 13:00:08 +08:00

204 lines
7.4 KiB
Go

// Package agent wires a Provider, a tool Registry, and a Session into the
// harness loop that drives a coding task to completion.
package agent
import (
"sync"
"reasonix/internal/provider"
)
// Session holds the conversation history for one task. The run loop (one turn at
// a time) is the only writer, but a frontend can read History/Save from another
// goroutine while a turn appends, so mu guards Messages. Direct Messages reads on
// the run-loop goroutine stay lock-free (serial with its own writes); cross-
// goroutine access goes through Snapshot.
type Session struct {
mu sync.RWMutex
Messages []provider.Message
version uint64
rewriteVersion int // bumped each time the log is rewritten (compact/fold)
// persistedRewriteVersion is the highest rewriteVersion whose transcript
// has fully reached disk. It lives on the Session — not on the controller
// — so swapping session objects can never orphan or misattribute the
// baseline: NeedsRewriteSave always compares a session against its own
// save history. Save paths advance it under s.mu with the rewriteVersion
// captured alongside the message snapshot, never a re-read one, so a
// compaction landing mid-save stays unpersisted.
persistedRewriteVersion int
persisted sessionPersistState
// normalizedDirty is set when LoadSession repaired the history on the way in
// (empty tool-call names, dangling calls, truncated args, …). The repair
// already lives in Messages, so the next Save persists it automatically as
// part of the usual full rewrite; the flag exists for observability and to
// let callers opt out of work that a dirty session would make redundant.
normalizedDirty bool
// eventLogDamaged is set when LoadSession found the on-disk event log torn
// or corrupt and returned the replayable prefix (or the .jsonl checkpoint).
// The next save heals the log with a rewrite-and-compact.
eventLogDamaged bool
// rawMessages preserves the pre-normalization transcript when the load-time
// repairs changed it (normalizedDirty). It is only meaningful on a freshly
// loaded Session: checkSnapshotWrite compares a pending snapshot against
// what is actually on disk, and the repaired view no longer represents
// those bytes — a session that kept running extends the raw transcript.
rawMessages []provider.Message
}
// NewSession initializes a session with an optional system prompt.
func NewSession(system string) *Session {
s := &Session{}
if system != "" {
s.Messages = append(s.Messages, provider.Message{Role: provider.RoleSystem, Content: system})
}
return s
}
// Add appends a message.
func (s *Session) Add(m provider.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.Messages = append(s.Messages, m)
s.version++
}
// Replace swaps the whole message log — used by compaction, which rewrites the
// middle of the history.
func (s *Session) Replace(msgs []provider.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.Messages = msgs
s.version++
}
// Snapshot returns a copy of the messages, safe to read from another goroutine
// while a turn appends. Frontends (History, Save) use it instead of touching the
// live slice.
func (s *Session) Snapshot() []provider.Message {
msgs, _, _ := s.snapshotWithVersion()
return msgs
}
// Len returns the number of messages, safe to call from any goroutine.
func (s *Session) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.Messages)
}
// CloneWithMessages returns a fresh Session carrying msgs while preserving the
// persistence baseline of the source session. Resume paths use this when they
// need to adjust loaded history before a rewrite; dropping persisted would make
// CAS treat the first legitimate rewrite as a stale-runtime conflict.
//
// Callers that are handed history from outside this Session should prefer
// CloneWithMessagesIfCompatible, so stale carried history cannot borrow a newer
// on-disk baseline.
func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
version := s.version
if !messagesEqualForStorageList(s.Messages, msgs) {
version++
}
return &Session{
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persistedRewriteVersion: s.persistedRewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
}
}
// CloneWithMessagesIfCompatible preserves the persistence baseline only when
// msgs is the same persisted history, optionally with a refreshed leading system
// prompt. Other history changes must happen after Resume so SaveRewrite can
// still detect genuine stale-controller conflicts.
func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Session, bool) {
if s == nil {
return nil, false
}
s.mu.RLock()
defer s.mu.RUnlock()
if !messagesCompatibleForStorageBaseline(s.Messages, msgs) {
return nil, false
}
version := s.version
if !messagesEqualForStorageList(s.Messages, msgs) {
version++
}
return &Session{
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persistedRewriteVersion: s.persistedRewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
}, true
}
// snapshotWithVersion returns the messages together with the version and
// rewriteVersion they were captured under, in one lock window: save paths
// persist exactly this rewriteVersion as the new baseline, so a rewrite that
// lands after the capture cannot be misrecorded as saved.
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
}
// RewriteVersion returns the current rewrite version.
func (s *Session) RewriteVersion() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.rewriteVersion
}
// NeedsRewriteSave reports whether the history has been rewritten in memory
// (compaction, prune) since the last successful full save of this session.
// Snapshot paths use it to decide that the next write must be an owned
// rewrite instead of an append.
func (s *Session) NeedsRewriteSave() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.rewriteVersion > s.persistedRewriteVersion
}
// IncrementRewrite bumps the rewrite version by 1.
func (s *Session) IncrementRewrite() {
s.mu.Lock()
defer s.mu.Unlock()
s.rewriteVersion++
s.version++
}
// HasContent returns true when the session carries at least one user,
// assistant, or tool message — i.e. more than just a system prompt. An
// "empty" conversation that has never been used should not be persisted.
func (s *Session) HasContent() bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, m := range s.Messages {
if m.Role != provider.RoleSystem {
return true
}
}
return false
}
// HasSystemMessage reports whether the session starts with a system message,
// which carries the agent's stable identity and behavioural contract. Sessions
// without one are not safe to persist: when reloaded the model has no identity
// context and falls back to its training-data defaults.
func (s *Session) HasSystemMessage() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.Messages) > 0 && s.Messages[0].Role == provider.RoleSystem
}