// Package agent holds the turn loop and the conversation state it runs on.
//
// It must not import the ui package: the eval runner in Phase 3 drives this
// loop headless, and anything terminal-shaped here would make that impossible.
package agent
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/codey/internal/tools"
)
// ReadRecord lives in tools so the edit tool can query the tracker without
// importing this package.
type ReadRecord = tools.ReadRecord
// State is the conversation history plus the per-session file-read tracking.
type State struct {
mu sync.Mutex
messages []llm.Message
reads map[string]ReadRecord
usage llm.Usage
turns int
}
func NewState() *State {
return &State{reads: map[string]ReadRecord{}}
}
// MarkRead implements tools.ReadTracker.
func (s *State) MarkRead(path string, mod time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.reads[path] = ReadRecord{At: time.Now(), Mod: mod}
}
// LastRead returns the read record for path, if the file has been read.
func (s *State) LastRead(path string) (ReadRecord, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.reads[path]
return r, ok
}
// Append adds a message to the history.
func (s *State) Append(m llm.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, m)
}
// Replace swaps the whole history, which is how compaction lands.
func (s *State) Replace(msgs []llm.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append([]llm.Message(nil), msgs...)
}
// SupersedeReads blanks the results of every read_file call for path except the
// most recent one. A file read twenty turns ago and edited since is not merely
// wasted context — it is wrong, and the model will reason from it.
func (s *State) SupersedeReads(path string) int {
s.mu.Lock()
defer s.mu.Unlock()
// Collect the tool_use ids of every read of this path, in order.
var ids []string
for _, m := range s.messages {
if m.Role != llm.RoleAssistant {
continue
}
for _, b := range m.Content {
if b.Type == llm.BlockToolUse && b.Name == "read_file" && readPath(b.Input) == path {
ids = append(ids, b.ID)
}
}
}
if len(ids) <= 1 {
return 0
}
stale := map[string]bool{}
for _, id := range ids[:len(ids)-1] {
stale[id] = true
}
var n int
for i, m := range s.messages {
if m.Role != llm.RoleUser {
continue
}
for j, b := range m.Content {
if b.Type != llm.BlockToolResult || !stale[b.ToolUseID] {
continue
}
if strings.HasPrefix(b.Content, stalePrefix) {
continue
}
s.messages[i].Content[j].Content = stalePrefix + path +
" has been modified since this read. The contents shown here are out of date; read it again if you need them.]"
// An image of the old file is as stale as its text, and far
// bigger; the notice above is all that goes back.
s.messages[i].Content[j].Blocks = nil
n++
}
}
return n
}
const stalePrefix = "[superseded: "
// readPath pulls the path argument out of a read_file call.
func readPath(input json.RawMessage) string {
var args struct {
Path string `json:"path"`
}
if json.Unmarshal(input, &args) != nil {
return ""
}
return args.Path
}
// History returns a copy of the conversation so far.
func (s *State) History() []llm.Message {
s.mu.Lock()
defer s.mu.Unlock()
return append([]llm.Message(nil), s.messages...)
}
// Len is the number of messages in the history.
func (s *State) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.messages)
}
// AddUsage accumulates the token counts of one response.
func (s *State) AddUsage(u llm.Usage) {
s.mu.Lock()
defer s.mu.Unlock()
s.usage.InputTokens += u.InputTokens
s.usage.OutputTokens += u.OutputTokens
s.usage.CacheReadInputTokens += u.CacheReadInputTokens
s.usage.CacheCreationInputTokens += u.CacheCreationInputTokens
s.turns++
}
// RestoreUsage sets the running totals outright, which is what resuming a
// saved session needs: AddUsage would count the whole restored history as one
// more turn.
func (s *State) RestoreUsage(u llm.Usage, turns int) {
s.mu.Lock()
defer s.mu.Unlock()
s.usage, s.turns = u, turns
}
// Usage is the running total for the session, and the number of model turns it
// took to get there.
func (s *State) Usage() (llm.Usage, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.usage, s.turns
}