204 lines
7.4 KiB
Go
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
|
|
}
|