Vectrum
Sign In
154 lines · 3.8 KB · Go
// 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"

	"harness/internal/llm"
	"harness/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.]"
			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++
}

// 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
}