session,dedup,compact: persistence, hard errors, summary display, write-then-write

Session persistence:
- Sessions saved to ~/.config/ollie/sessions/{id}.json after each
  completed agent turn and after /compact
- --session <id> flag resumes a saved session, auto-loading its agent
- /sessions command lists saved sessions with agent and goal preview
- /clear and /agent switch generate a new session ID
- Session ID shown at startup

Dedup:
- file_read: overlap check now returns a hard error (was a warning
  prepended to the result); check uses requested range, not actual
  read range, to avoid redundant dispatchFileRead on overlap
- tool calls: duplicate (name, args) now returns a hard error; key
  is only recorded on successful execution

/compact: Compact() now returns the summary text; displayed in the
  UI so the user can verify quality; session saved after compact

Write-then-write:
- After a successful file_write, repopulate fileRanges with the
  written range so a follow-up write to the same region does not
  require a re-read
- Whole-file writes record exact new line count from content;
  range writes record the written [start, end] with totalLines=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-07 10:46:01 +02:00
parent 864d13381a
commit bc8c6b75b5
2 changed files with 227 additions and 27 deletions

View File

@ -2,12 +2,51 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"ollie/backend"
)
// PersistedSession is the on-disk format for a saved session.
type PersistedSession struct {
ID string `json:"id"`
Agent string `json:"agent,omitempty"`
Messages []backend.Message `json:"messages"`
}
// SaveTo writes the full message history to path as JSON.
func (s *Session) SaveTo(path, id, agentName string) error {
ps := PersistedSession{
ID: id,
Agent: agentName,
Messages: s.ctx.Messages(),
}
data, err := json.Marshal(ps)
if err != nil {
return fmt.Errorf("session save: %w", err)
}
return os.WriteFile(path, data, 0600)
}
// RestoreSession reconstructs a Session from a persisted message list,
// applying cfg to the ContextBuilder.
func RestoreSession(messages []backend.Message, cfg ContextConfig) *Session {
s := &Session{ctx: NewContextBuilder(cfg)}
for _, m := range messages {
s.ctx.Append(m)
}
for _, m := range messages {
if m.Role == "user" {
s.goal = m.Content
break
}
}
return s
}
// Session is an ephemeral in-memory state backend.
// It lives only for the duration of the process; nothing is persisted.
//
@ -65,11 +104,11 @@ func (s *Session) MarkComplete() error {
// Compact summarizes messages outside the bounded window via an LLM call,
// replacing them with a single summary system message.
// Returns the number of messages replaced, or 0 if nothing to compact.
func (s *Session) Compact(ctx context.Context, b backend.Backend, model string) (int, error) {
// Returns (n evicted, summary text, error); n==0 means nothing to compact.
func (s *Session) Compact(ctx context.Context, b backend.Backend, model string) (int, string, error) {
evicted := s.ctx.EvictedMessages()
if len(evicted) == 0 {
return 0, nil
return 0, "", nil
}
// Build a prompt asking the model to summarize the evicted messages.
@ -83,7 +122,7 @@ func (s *Session) Compact(ctx context.Context, b backend.Backend, model string)
{Role: "user", Content: prompt},
}, nil, backend.GenerationParams{})
if err != nil {
return 0, fmt.Errorf("compact: %w", err)
return 0, "", fmt.Errorf("compact: %w", err)
}
var summary strings.Builder
@ -96,18 +135,20 @@ func (s *Session) Compact(ctx context.Context, b backend.Backend, model string)
}
}
summaryText := strings.TrimSpace(summary.String())
// Replace evicted messages with summary.
all := s.ctx.Messages()
s.ctx.Truncate(0)
s.ctx.Append(backend.Message{
Role: "system",
Content: "[conversation summary: " + strings.TrimSpace(summary.String()) + "]",
Content: "[conversation summary: " + summaryText + "]",
})
// Re-append the non-evicted messages.
for _, m := range all[len(evicted):] {
s.ctx.Append(m)
}
return len(evicted), nil
return len(evicted), summaryText, nil
}
// Rollback removes any trailing non-user messages from history, discarding

201
main.go
View File

@ -3,7 +3,9 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"log"
"os"
@ -211,6 +213,8 @@ type model struct {
confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run
ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas)
invalidateCaches func() // clears tool-call dedup caches; set from agentEnv
sessionsDir string
sessionID string
quitPending bool // whether a second Ctrl+C should quit
lastCtrlC time.Time // timestamp of last Ctrl+C press
@ -365,16 +369,32 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
Path string `json:"path"`
}
json.Unmarshal(args, &a) //nolint:errcheck
if a.Path != "" {
if st := fileRanges[a.Path]; st != nil {
var reqStart, reqEnd int
var tmp struct {
Start int `json:"start_line"`
End int `json:"end_line"`
}
json.Unmarshal(args, &tmp) //nolint:errcheck
reqStart, reqEnd = tmp.Start, tmp.End
if reqStart <= 0 {
reqStart = 1
}
if reqEnd <= 0 {
reqEnd = st.totalLines
}
if reqEnd > 0 && rangesOverlap(st.ranges, reqStart, reqEnd) {
return "", fmt.Errorf("file_read: lines %d-%d of %s are already in context this session; use existing content instead of re-reading", reqStart, reqEnd, a.Path)
}
}
}
meta, err := dispatchFileRead(cfn, args)
if err != nil {
return "", err
}
content := meta.content
if a.Path != "" {
st := fileRanges[a.Path]
if st != nil && rangesOverlap(st.ranges, meta.start, meta.end) {
content = fmt.Sprintf("[WARNING: Lines %d-%d of this file were already read this session. Do not re-read ranges already in your context.]\n", meta.start, meta.end) + content
}
if st == nil {
st = &fileReadState{}
fileRanges[a.Path] = st
@ -382,12 +402,13 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
st.ranges = append(st.ranges, lineRange{meta.start, meta.end})
st.totalLines = meta.totalLines
}
return content, nil
return meta.content, nil
}
if name == "file_write" {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
@ -416,10 +437,28 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
if !rangesCover(st.ranges, ws, we) {
return "", fmt.Errorf("file_write: lines %d-%d of %s have not been read this session; read them first to avoid overwriting unknown changes", ws, we, a.Path)
}
// Invalidate: file content has changed, any cached ranges are stale.
delete(fileRanges, a.Path)
}
}
result, err := dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
if err != nil {
return "", err
}
// Repopulate fileRanges so follow-up writes to the same range
// don't require a re-read.
if a.Path != "" {
ws, we := a.StartLine, a.EndLine
totalLines := 0
if ws == 0 && we == 0 {
// Whole-file: we know the exact new line count.
totalLines = len(strings.Split(a.Content, "\n"))
ws, we = 1, totalLines
}
fileRanges[a.Path] = &fileReadState{
ranges: []lineRange{{ws, we}},
totalLines: totalLines,
}
}
return result, nil
}
return dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
@ -428,17 +467,19 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
}
execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
result, err := rawExec(ctx, name, args)
// Warn on repeated identical tool calls (excluding file_read/file_write which
// are handled separately above).
if err == nil && name != "file_read" && name != "file_write" {
// Hard-error on duplicate tool calls (file_read/file_write handled above).
if name != "file_read" && name != "file_write" {
key := name + "\x00" + string(args)
if toolCallSeen[key] {
result = "[WARNING: This exact tool call was already made this session. Result may be unchanged. Do not repeat unless something has changed.]\n" + result
return "", fmt.Errorf("duplicate tool call: %s with these exact arguments was already called this session; the result is already in your context", name)
}
toolCallSeen[key] = true
result, err := rawExec(ctx, name, args)
if err == nil {
toolCallSeen[key] = true
}
return result, err
}
return result, err
return rawExec(ctx, name, args)
}
return agentEnv{
@ -473,9 +514,36 @@ func agentConfigPath(agentsDir, name string) string {
return p // will produce a load error, which is the right behaviour
}
// newSessionID returns a unique session identifier embedding the current timestamp.
func newSessionID() string {
b := make([]byte, 3)
rand.Read(b) //nolint:errcheck
return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b)
}
// saveSession persists the current session to disk; silently no-ops if no
// active session or session ID is set.
func (m *model) saveSession() {
if m.session == nil || m.sessionID == "" || m.sessionsDir == "" {
return
}
path := m.sessionsDir + "/" + m.sessionID + ".json"
if err := m.session.SaveTo(path, m.sessionID, m.agentName); err != nil {
m.appendDisplay("session save: " + err.Error())
}
}
func main() {
sessionFlag := flag.String("session", "", "resume a session by ID")
flag.Parse()
extraArgs := flag.Args()
home, _ := os.UserHomeDir()
agentsDir := home + "/.config/ollie/agents"
sessionsDir := home + "/.config/ollie/sessions"
if err := os.MkdirAll(sessionsDir, 0700); err != nil {
log.Fatalf("sessions dir: %v", err)
}
be, err := backend.New()
if err != nil {
@ -495,27 +563,58 @@ func main() {
home+"/.cache/ollie/exec",
)
// Load the default agent config.
// Determine agent name: explicit arg > session file > env > "default".
agentName := os.Getenv("OLLIE_AGENT")
if agentName == "" {
agentName = "default"
}
if len(os.Args) > 1 {
agentName = os.Args[1]
// If resuming a session, peek at the file to get the stored agent name
// (used only when no agent is specified on the command line).
sessionID := newSessionID()
var resumeMessages []backend.Message
if *sessionFlag != "" {
sessionPath := sessionsDir + "/" + *sessionFlag + ".json"
data, readErr := os.ReadFile(sessionPath)
if readErr != nil {
log.Fatalf("--session: %v", readErr)
}
var ps agent.PersistedSession
if jsonErr := json.Unmarshal(data, &ps); jsonErr != nil {
log.Fatalf("--session: bad JSON: %v", jsonErr)
}
sessionID = ps.ID
resumeMessages = ps.Messages
if ps.Agent != "" && len(extraArgs) == 0 {
agentName = ps.Agent
}
}
if len(extraArgs) > 0 {
agentName = extraArgs[0]
}
cfgPath := agentConfigPath(agentsDir, agentName)
cfg, cfgErr := config.Load(cfgPath)
var startup []string
if cfgErr != nil && len(os.Args) > 2 {
log.Fatalf("Failed to load agent config: %v", cfgErr)
} else if cfgErr != nil {
if cfgErr != nil {
startup = append(startup, fmt.Sprintf("agent config: %v", cfgErr))
}
env := buildAgentEnv(cfg, builtinExec)
startup = append(startup, env.messages...)
// Restore or record session info.
var initialSession *agent.Session
if len(resumeMessages) > 0 {
initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{
FixedOverheadChars: env.ctxOverhead,
})
startup = append(startup, fmt.Sprintf("session: %s (resumed)", sessionID))
} else {
startup = append(startup, fmt.Sprintf("session: %s", sessionID))
}
loopcfg := agent.Config{
Backend: be,
Model: modelName,
@ -548,6 +647,7 @@ func main() {
p := tea.NewProgram(model{
textarea: ta,
viewport: vp,
session: initialSession,
loopcfg: loopcfg,
hooks: env.hooks,
display: startup,
@ -555,6 +655,8 @@ func main() {
backendName: backendName,
agentName: agentName,
agentsDir: agentsDir,
sessionsDir: sessionsDir,
sessionID: sessionID,
mcpExec: env.mcpExec,
builtinExec: builtinExec,
confirmPtr: env.confirm,
@ -788,6 +890,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.agentCh = nil
m.cancel = nil
m.doneCh = nil
m.saveSession()
return m, nil
}
// Stop pumping while waiting for user confirmation.
@ -1014,6 +1117,7 @@ func (m *model) handleCommand(input string) bool {
m.invalidateCaches = env.invalidateCaches
m.agentName = name
m.session = nil // new agent, new session
m.sessionID = newSessionID()
for _, msg := range env.messages {
m.display = append(m.display, msg)
}
@ -1025,13 +1129,20 @@ func (m *model) handleCommand(input string) bool {
m.display = append(m.display, "nothing to compact")
return true
}
n, err := m.session.Compact(context.Background(), m.loopcfg.Backend, m.loopcfg.Model)
n, summary, err := m.session.Compact(context.Background(), m.loopcfg.Backend, m.loopcfg.Model)
if err != nil {
m.display = append(m.display, "compact error: "+err.Error())
} else if n == 0 {
m.display = append(m.display, "nothing to compact")
} else {
m.display = append(m.display, fmt.Sprintf("compacted %d messages", n))
if summary != "" {
m.display = append(m.display, "")
for _, line := range strings.Split(summary, "\n") {
m.display = append(m.display, line)
}
}
m.saveSession()
if m.invalidateCaches != nil {
m.invalidateCaches()
}
@ -1068,14 +1179,62 @@ func (m *model) handleCommand(input string) bool {
m.buf = ""
m.ctxStats = agent.ContextStats{}
m.lastUsage = backend.Usage{}
m.sessionID = newSessionID()
if m.invalidateCaches != nil {
m.invalidateCaches()
}
return true
case "/sessions":
entries, err := os.ReadDir(m.sessionsDir)
if err != nil {
m.display = append(m.display, fmt.Sprintf("sessions: %v", err))
return true
}
found := false
// ReadDir returns sorted by name (date-prefixed IDs → chronological).
// Show most recent first.
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
id := strings.TrimSuffix(e.Name(), ".json")
marker := " "
if id == m.sessionID {
marker = "* "
}
// Quick-read agent name and first user message for context.
label := id
if data, readErr := os.ReadFile(m.sessionsDir + "/" + e.Name()); readErr == nil {
var ps agent.PersistedSession
if json.Unmarshal(data, &ps) == nil {
agentLabel := ps.Agent
goal := ""
for _, msg := range ps.Messages {
if msg.Role == "user" {
goal = msg.Content
break
}
}
if len(goal) > 60 {
goal = goal[:60] + "..."
}
label = fmt.Sprintf("%-24s [%s] %q", id, agentLabel, goal)
}
}
m.display = append(m.display, marker+label)
found = true
}
if !found {
m.display = append(m.display, "no sessions found in "+m.sessionsDir)
}
return true
case "/help":
m.display = append(m.display, "Available commands:")
m.display = append(m.display, " /agents - List available agent configs")
m.display = append(m.display, " /sessions - List saved sessions")
m.display = append(m.display, " /agent [name] - Show or switch active agent")
m.display = append(m.display, " /backend <type> - Switch backend (ollama, openai)")
m.display = append(m.display, " /model <name> - Switch model")