From 498278665f867556c7223cab7c76de279c30c86c Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 29 Jul 2026 18:21:34 +0200 Subject: [PATCH] =?UTF-8?q?rename=20agent.Session=20=E2=86=92=20agent.Hist?= =?UTF-8?q?ory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Session type in agent/ is purely a conversation accumulator (message history, token tracking, compaction state). Rename it to History to free up 'Session' for the runtime environment concept in the upcoming agent/session split. --- agent/commands.go | 38 ++++++------ agent/core.go | 148 ++++++++++++++++++++++----------------------- agent/core_test.go | 62 +++++++++---------- agent/session.go | 56 ++++++++--------- agent/usage_log.go | 2 +- 5 files changed, 153 insertions(+), 153 deletions(-) diff --git a/agent/commands.go b/agent/commands.go index 6869298..8c4e8e1 100644 --- a/agent/commands.go +++ b/agent/commands.go @@ -196,7 +196,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { } s.runtime = rt s.agentName = name - s.session = nil + s.history = nil s.pushSessionEnv() s.notifyChange() for _, msg := range rt.Messages { @@ -210,11 +210,11 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent("error: cannot compact while agent is running")) return } - if s.session == nil { + if s.history == nil { s.emit(infoEvent("nothing to compact")) return } - snapshot := s.session.PreCompactionSnapshot() + snapshot := s.history.PreCompactionSnapshot() s.setState("compacting") n, err := s.runCompact(ctx, "manual") s.setState("idle") @@ -243,7 +243,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { }, "/context": func(args []string) { - if s.session == nil { + if s.history == nil { s.emit(infoEvent("no active session")) return } @@ -251,23 +251,23 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := s.session.estimateTokens() + estimated := s.history.estimateTokens() pct := estimated * 100 / ctxLen s.emit(infoEvent(fmt.Sprintf("~%d / %d tokens (%d%%)", estimated, ctxLen, pct))) - s.emit(infoEvent(strings.TrimRight(s.session.contextDebug(), "\n"))) + s.emit(infoEvent(strings.TrimRight(s.history.contextDebug(), "\n"))) }, "/cost": func(args []string) { - if s.session == nil { + if s.history == nil { s.emit(infoEvent("no active session")) return } s.emit(infoEvent(fmt.Sprintf("last=$%.4f session=$%.4f", - s.session.LastTurnCostUSD, s.session.SessionCostUSD))) + s.history.LastTurnCostUSD, s.history.SessionCostUSD))) }, "/usage": func(args []string) { - if s.session == nil { + if s.history == nil { s.emit(infoEvent("no active session")) return } @@ -275,24 +275,24 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := s.session.estimateTokens() + estimated := s.history.estimateTokens() pct := estimated * 100 / ctxLen usageStr := fmt.Sprintf("~%d / %d tokens (%d%%) | %d in, %d out, %d requests", estimated, ctxLen, pct, - s.session.TotalInputTokens, s.session.TotalOutputTokens, - s.session.TotalRequests) - if s.session.Estimated { + s.history.TotalInputTokens, s.history.TotalOutputTokens, + s.history.TotalRequests) + if s.history.Estimated { usageStr += " [estimated]" } s.emit(infoEvent(usageStr)) }, "/history": func(args []string) { - if s.session == nil { + if s.history == nil { s.emit(infoEvent("no active session")) return } - for _, msg := range s.session.history() { + for _, msg := range s.history.history() { preview := msg.Content if len(preview) > 200 { preview = preview[:200] + "..." @@ -306,7 +306,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent("error: cannot clear while agent is running")) return } - s.session = nil + s.history = nil s.emit(infoEvent("cleared")) }, @@ -373,7 +373,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { }, "/save": func(args []string) { - if s.session == nil { + if s.history == nil { s.emit(infoEvent("error: no active session")) return } @@ -383,7 +383,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { } name := args[0] path := s.sessionsDir + "/" + name + ".json" - if err := s.session.saveTo(path, name, s.agentName, s.CWD()); err != nil { + if err := s.history.saveTo(path, name, s.agentName, s.CWD()); err != nil { s.emit(infoEvent("error: " + err.Error())) return } @@ -411,7 +411,7 @@ func (s *agent) handleCommand(ctx context.Context, input string) bool { s.emit(infoEvent(fmt.Sprintf("error: %v", err))) return } - s.session = RestoreSession(&ps) + s.history = RestoreHistory(&ps) s.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages)))) }, diff --git a/agent/core.go b/agent/core.go index c602104..706c19f 100644 --- a/agent/core.go +++ b/agent/core.go @@ -279,7 +279,7 @@ type AgentCoreConfig struct { SessionID string Uname string // immutable user principal for 9P identity CWD string // working directory for tool execution and system prompt - Session *Session + History *History Runtime *Runtime NewDispatcher func() tools.Dispatcher NewBackend func(string) (backend.Backend, error) // if nil, defaults to backend.NewWithName @@ -309,7 +309,7 @@ type AgentCoreConfig struct { // agent is the Core implementation. It owns all agent and session state // but has no knowledge of how output is rendered. type agent struct { - session *Session + history *History runtime *Runtime cfg agentConfig // per-turn config built from runtime; set in executeTurn log *olog.Logger @@ -478,7 +478,7 @@ func NewAgentCore(cfg AgentCoreConfig) Core { } a := &agent{ - session: cfg.Session, + history: cfg.History, runtime: rt, log: log, auditLog: log.Sub("audit"), @@ -646,10 +646,10 @@ func classifyReaction(emoji string) (category, description string, positive bool func (s *agent) Reactions() map[string]string { result := make(map[string]string) - if s.session == nil { + if s.history == nil { return result } - for _, reaction := range s.session.Reactions { + for _, reaction := range s.history.Reactions { result[reaction.ResponseID] = reaction.Emoji } return result @@ -660,7 +660,7 @@ func (s *agent) React(emoji string) { } func (s *agent) ReactTo(responseID, emoji string) error { - if s.session == nil { + if s.history == nil { return fmt.Errorf("no active session") } category, _, _ := classifyReaction(emoji) @@ -668,9 +668,9 @@ func (s *agent) ReactTo(responseID, emoji string) error { return fmt.Errorf("unsupported reaction: %s", emoji) } if responseID == "" { - for i := len(s.session.messages) - 1; i >= 0; i-- { - if s.session.messages[i].Role == "assistant" { - responseID = s.session.messages[i].ID + for i := len(s.history.messages) - 1; i >= 0; i-- { + if s.history.messages[i].Role == "assistant" { + responseID = s.history.messages[i].ID break } } @@ -679,8 +679,8 @@ func (s *agent) ReactTo(responseID, emoji string) error { return fmt.Errorf("no assistant response to react to") } found := false - for i := range s.session.messages { - if s.session.messages[i].Role == "assistant" && s.session.messages[i].ID == responseID { + for i := range s.history.messages { + if s.history.messages[i].Role == "assistant" && s.history.messages[i].ID == responseID { found = true break } @@ -691,20 +691,20 @@ func (s *agent) ReactTo(responseID, emoji string) error { reaction := Reaction{ID: NewReactionID(), ResponseID: responseID, Emoji: emoji, Category: category, CreatedAt: time.Now()} replaced := false - for i := range s.session.Reactions { - if s.session.Reactions[i].ResponseID == responseID { - if s.session.Reactions[i].Emoji == emoji { + for i := range s.history.Reactions { + if s.history.Reactions[i].ResponseID == responseID { + if s.history.Reactions[i].Emoji == emoji { return nil } - s.session.Reactions[i] = reaction + s.history.Reactions[i] = reaction replaced = true break } } if !replaced { - s.session.Reactions = append(s.session.Reactions, reaction) + s.history.Reactions = append(s.history.Reactions, reaction) } - s.session.recomputeReactionCounts() + s.history.recomputeReactionCounts() s.saveSession() return nil } @@ -926,7 +926,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { return 0, nil } if pre.Context != "" { - s.session.appendUserMessage(pre.Context) + s.history.appendUserMessage(pre.Context) } // Use a cheaper model for compaction if configured. compactModel := resolveCompactionModel(s.runtime.CompactionModel, s.runtime.Backend) @@ -935,7 +935,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { s.runtime.Backend.SetModel(compactModel) defer s.runtime.Backend.SetModel(origModel) } - n, _, err := s.session.compact(ctx, s.runtime.Backend) + n, _, err := s.history.compact(ctx, s.runtime.Backend) if err != nil { return 0, err } @@ -943,7 +943,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { s.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, s.sessionID) s.warnedContext = false if sc := s.spawnContext(ctx); sc != "" { - s.session.appendUserMessage(sc) + s.history.appendUserMessage(sc) } } post := s.runtime.Hooks.Run(ctx, HookPostCompact, payload, s.log) @@ -954,7 +954,7 @@ func (s *agent) runCompact(ctx context.Context, trigger string) (int, error) { s.emit(infoEvent("postCompact: " + sum)) } if post.Context != "" { - s.session.appendUserMessage(post.Context) + s.history.appendUserMessage(post.Context) } return n, nil } @@ -985,7 +985,7 @@ func (s *agent) flushSave() { if !dirty { return } - if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { + if s.history == nil || s.sessionID == "" || s.sessionsDir == "" { return } path := s.activeSessionPath(s.sessionID, ".json") @@ -993,7 +993,7 @@ func (s *agent) flushSave() { s.log.Error("session save: %v", err) return } - if err := s.session.saveToFull(path, s.sessionID, s.agentName, + if err := s.history.saveToFull(path, s.sessionID, s.agentName, s.runtime.Backend.Name(), s.runtime.Backend.Model(), s.CWD(), s.remote); err != nil { s.log.Error("session save: %v", err) } @@ -1004,10 +1004,10 @@ func (s *agent) flushSave() { func (s *agent) SaveSession(path string) error { s.mu.RLock() defer s.mu.RUnlock() - if s.session == nil { + if s.history == nil { return fmt.Errorf("no active session") } - return s.session.saveToFull(path, s.sessionID, s.agentName, + return s.history.saveToFull(path, s.sessionID, s.agentName, s.runtime.Backend.Name(), s.runtime.Backend.Model(), s.CWD(), s.remote) } @@ -1082,7 +1082,7 @@ func (s *agent) IsRunning() bool { } func (s *agent) CtxSz() string { - if s.session == nil { + if s.history == nil { s.log.Debug("CtxSz() no session") return "no active session" } @@ -1090,7 +1090,7 @@ func (s *agent) CtxSz() string { if ctxLen <= 0 { ctxLen = defaultContextLength } - estimated := s.session.estimateTokens() + estimated := s.history.estimateTokens() pct := estimated * 100 / ctxLen v := fmt.Sprintf("%d / %d (%d%%)", estimated, ctxLen, pct) s.log.Debug("CtxSz() = %q", v) @@ -1098,25 +1098,25 @@ func (s *agent) CtxSz() string { } func (s *agent) Cost() string { - if s.session == nil { + if s.history == nil { return "no active session" } return fmt.Sprintf("costLast=$%.4f\ncostSession=$%.4f\n", - s.session.LastTurnCostUSD, s.session.SessionCostUSD) + s.history.LastTurnCostUSD, s.history.SessionCostUSD) } func (s *agent) Usage() string { - if s.session == nil { + if s.history == nil { s.log.Debug("Usage() no session") return "no active session" } str := fmt.Sprintf("%d in, %d out, %d requests", - s.session.TotalInputTokens, s.session.TotalOutputTokens, - s.session.TotalRequests) - if s.session.TotalCachedInputTokens > 0 { - str += fmt.Sprintf(", %d cached", s.session.TotalCachedInputTokens) + s.history.TotalInputTokens, s.history.TotalOutputTokens, + s.history.TotalRequests) + if s.history.TotalCachedInputTokens > 0 { + str += fmt.Sprintf(", %d cached", s.history.TotalCachedInputTokens) } - if s.session.Estimated { + if s.history.Estimated { str += " [estimated]" } s.log.Debug("Usage() = %q", str) @@ -1126,8 +1126,8 @@ func (s *agent) Usage() string { func (s *agent) Context() []backend.Message { s.mu.RLock() var msgs []backend.Message - if s.session != nil { - msgs = slices.Clone(s.session.history()) + if s.history != nil { + msgs = slices.Clone(s.history.history()) } s.mu.RUnlock() if s.runtime.Preamble != "" { @@ -1269,25 +1269,25 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { // Snapshot session state before this turn modifies it. Restored on failure // so the session is clean for the next attempt. - snapSession := s.session + snapSession := s.history var snapMessages []backend.Message - if s.session != nil { - snapMessages = cloneMessages(s.session.messages) + if s.history != nil { + snapMessages = cloneMessages(s.history.messages) } - if s.session == nil { + if s.history == nil { for _, msg := range s.startupMessages { s.log.Debug("startup: %s", msg) s.emit(infoEvent(msg)) } s.startupMessages = nil - s.session = newSession(input) + s.history = newHistory(input) if sc := s.spawnContext(ctx); sc != "" { - s.session.appendUserMessage(sc) + s.history.appendUserMessage(sc) } - s.session.appendUserMessage(input) + s.history.appendUserMessage(input) } else { - s.session.appendUserMessage(input) + s.history.appendUserMessage(input) } actCtx, actCancel := context.WithCancelCause(ctx) @@ -1328,11 +1328,11 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { case "error": s.auditLog.Debug("error: %s", ev.Content) } - if ev.Role == "usage" && s.session != nil { + if ev.Role == "usage" && s.history != nil { var in, out, est, cached, creation int var costUSD float64 fmt.Sscanf(ev.Content, "%d %d %d %g %d %d", &in, &out, &est, &costUSD, &cached, &creation) - s.session.addUsage(backend.Usage{ + s.history.addUsage(backend.Usage{ InputTokens: in, CachedInputTokens: cached, CacheCreationTokens: creation, @@ -1341,7 +1341,7 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { }, est != 0) s.notifyChange() if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { - if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && s.session.SessionCostUSD >= limit { + if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && s.history.SessionCostUSD >= limit { s.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit))) s.Interrupt(ErrInterrupted) } @@ -1378,11 +1378,11 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { s.cfg.SaveSession = func() { s.saveSession() } s.cfg.ResultCache = &s.resultCache s.cfg.AutoCompact = func(ctx context.Context) { - if ctx.Err() != nil || s.session == nil { + if ctx.Err() != nil || s.history == nil { return } limit := s.autoCompactLimit(ctx) - if limit <= 0 || s.session.estimateTokens() < limit { + if limit <= 0 || s.history.estimateTokens() < limit { return } s.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) @@ -1394,8 +1394,8 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { } // Warn once when context usage crosses 60%; compact at 75%. - if s.session != nil { - tokens := s.session.estimateTokens() + if s.history != nil { + tokens := s.history.estimateTokens() if compactLimit := s.autoCompactLimit(ctx); compactLimit > 0 && tokens >= compactLimit { s.emit(Event{Role: "info", Content: "auto-compacting context...\n"}) s.setState("compacting") @@ -1415,25 +1415,25 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { } // Spending cap: reject before spending more tokens. - if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && s.session != nil { + if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && s.history != nil { if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 { - if s.session.SessionCostUSD >= limit { - s.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, s.session.SessionCostUSD)}) + if s.history.SessionCostUSD >= limit { + s.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, s.history.SessionCostUSD)}) s.setState("idle") actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) if snapSession == nil { - s.session = nil + s.history = nil } else { - s.session.messages = snapMessages + s.history.messages = snapMessages } return "" } } } - if s.session != nil { - s.session.resetTurnAccumulators() + if s.history != nil { + s.history.resetTurnAccumulators() } // Run the turn, retrying once after compaction on context overflow. @@ -1442,7 +1442,7 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { err error ) for { - err = run(actCtx, s.cfg, s.session) + err = run(actCtx, s.cfg, s.history) actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) @@ -1453,17 +1453,17 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { break } var ctxErr *backend.ContextOverflowError - if !overflowRetried && errors.As(err, &ctxErr) && s.session != nil { + if !overflowRetried && errors.As(err, &ctxErr) && s.history != nil { overflowRetried = true - s.session.messages = snapMessages + s.history.messages = snapMessages s.emit(Event{Role: "info", Content: "context overflow — compacting and retrying...\n"}) s.setState("compacting") if _, cerr := s.runCompact(ctx, "overflow"); cerr != nil { break } - s.session.appendUserMessage(input) + s.history.appendUserMessage(input) s.setState("thinking") - s.session.resetTurnAccumulators() + s.history.resetTurnAccumulators() replyBuf.Reset() actCtx, actCancel = context.WithCancelCause(ctx) handle = &actionHandle{cancel: actCancel} @@ -1483,8 +1483,8 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { if err != nil { // Keep completed work — only remove cancelled tool results. // Error results are valuable feedback for the agent. - if s.session != nil { - s.session.removeCancelledToolResults() + if s.history != nil { + s.history.removeCancelledToolResults() } if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) { s.auditLog.Debug("turn: interrupted session=%s", s.sessionID) @@ -1509,18 +1509,18 @@ func (s *agent) executeTurn(ctx context.Context, input string) string { if sum := stopResult.Summary(); sum != "" { s.emit(infoEvent("postTurn: " + sum)) } - if !stopResult.Blocked && stopResult.Context != "" && s.session != nil { - s.session.appendUserMessage(stopResult.Context) + if !stopResult.Blocked && stopResult.Context != "" && s.history != nil { + s.history.appendUserMessage(stopResult.Context) } - if s.session != nil { - s.session.recordTurnCost(s.cfg.Backend.Model()) - appendUsageLog(s.sessionID, s.cfg.Backend.Name(), s.cfg.Backend.Model(), s.session) - if s.session.LastTurnCostUSD > 0 { - s.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", s.session.LastTurnCostUSD)}) + if s.history != nil { + s.history.recordTurnCost(s.cfg.Backend.Model()) + appendUsageLog(s.sessionID, s.cfg.Backend.Name(), s.cfg.Backend.Model(), s.history) + if s.history.LastTurnCostUSD > 0 { + s.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", s.history.LastTurnCostUSD)}) } s.auditLog.Debug("turn: end reply=%s cost=$%.4f session_total=$%.4f session=%s", - auditTruncate(s.reply), s.session.LastTurnCostUSD, s.session.SessionCostUSD, s.sessionID) + auditTruncate(s.reply), s.history.LastTurnCostUSD, s.history.SessionCostUSD, s.sessionID) s.notifyChange() } s.saveSession() diff --git a/agent/core_test.go b/agent/core_test.go index dd893a6..998269f 100644 --- a/agent/core_test.go +++ b/agent/core_test.go @@ -460,21 +460,21 @@ func TestManualCompact(t *testing.T) { } // Seed a session with more than hotTailSize+warmIndexSize messages so compact() doesn't short-circuit. - c.session = newSession("goal") + c.history = newHistory("goal") for i := range 15 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d with enough text to count", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) } - before := len(c.session.messages) + before := len(c.history.messages) evs := collectEvents(context.Background(), c, "/compact") - if c.session == nil { + if c.history == nil { t.Fatal("session nil after compact") } - if after := len(c.session.messages); after >= before { + if after := len(c.history.messages); after >= before { t.Errorf("messages: before=%d after=%d; want fewer after compact", before, after) } if callCount == 0 { @@ -514,9 +514,9 @@ func TestAutoCompact(t *testing.T) { } - c.session = newSession("goal") + c.history = newHistory("goal") for range 15 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"}, backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"}, ) @@ -600,12 +600,12 @@ func TestCommand_Help(t *testing.T) { func TestCommand_Clear(t *testing.T) { c := newCore(t, nil, nil) collectEvents(context.Background(), c, "first turn") - if c.session == nil { + if c.history == nil { t.Fatal("session nil after first turn") } oldID := c.sessionID collectEvents(context.Background(), c, "/clear") - if c.session != nil { + if c.history != nil { t.Error("session not nil after /clear") } if c.sessionID != oldID { @@ -785,9 +785,9 @@ func TestCommand_Compact_NilSession(t *testing.T) { func TestCommand_Compact_PreHookBlocks(t *testing.T) { c := newCore(t, nil, Hooks{HookPreCompact: []string{"exit 2"}}) - c.session = newSession("goal") + c.history = newHistory("goal") for i := range 5 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -1625,9 +1625,9 @@ func TestAutoCompact_WithHookContext(t *testing.T) { return textStream("answer"), nil } - c.session = newSession("goal") + c.history = newHistory("goal") for range 15 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"}, backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"}, ) @@ -1844,7 +1844,7 @@ func TestAgentSpawn_WithContext(t *testing.T) { collectEvents(context.Background(), c, "first") // spawn context goes into session history, not preamble found := false - for _, m := range c.session.messages { + for _, m := range c.history.messages { if strings.Contains(m.Content, "spawn context") { found = true break @@ -1887,9 +1887,9 @@ func TestManualCompact_BackendError(t *testing.T) { callCount++ return nil, fmt.Errorf("compact backend error") } - c.session = newSession("goal") + c.history = newHistory("goal") for i := range 15 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -1921,9 +1921,9 @@ func TestManualCompact_EmptySummary(t *testing.T) { return ch, nil } - c.session = newSession("goal") + c.history = newHistory("goal") for i := range 15 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) @@ -1949,15 +1949,15 @@ func TestManualCompact_WithToolMessages(t *testing.T) { return textStream("summary"), nil } - c.session = newSession("goal") + c.history = newHistory("goal") // Seed enough messages to pass the compaction threshold. for i := range 10 { - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, ) } - c.session.messages = append(c.session.messages, + c.history.messages = append(c.history.messages, backend.Message{ Role: "assistant", Content: "calling tool", @@ -2155,7 +2155,7 @@ func TestRestoreSession_RoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sess.json") - s := newSession("first user message") + s := newHistory("first user message") s.appendUserMessage("first user message") s.appendUserMessage("second message") s.messages = append(s.messages, backend.Message{Role: "assistant", Content: "reply"}) @@ -2176,7 +2176,7 @@ func TestRestoreSession_RoundTrip(t *testing.T) { t.Errorf("ps.ID=%q ps.Agent=%q", ps.ID, ps.Agent) } - restored := RestoreSession(&ps) + restored := RestoreHistory(&ps) if restored.goal != "first user message" { t.Errorf("goal = %q; want %q", restored.goal, "first user message") } @@ -2187,21 +2187,21 @@ func TestRestoreSession_RoundTrip(t *testing.T) { func TestReactionTargetsResponseAndReplaces(t *testing.T) { c := newCore(t, nil, nil) - c.session = &Session{messages: []backend.Message{ + c.history = &History{messages: []backend.Message{ {Role: "assistant", ID: "r1", Content: "first"}, {Role: "assistant", ID: "r2", Content: "second"}, }} if err := c.ReactTo("r1", "👍"); err != nil { t.Fatalf("ReactTo: %v", err) } - if len(c.session.Reactions) != 1 || c.session.Reactions[0].ResponseID != "r1" || c.session.PositiveReactions != 1 { - t.Fatalf("reaction state = %+v positive=%d", c.session.Reactions, c.session.PositiveReactions) + if len(c.history.Reactions) != 1 || c.history.Reactions[0].ResponseID != "r1" || c.history.PositiveReactions != 1 { + t.Fatalf("reaction state = %+v positive=%d", c.history.Reactions, c.history.PositiveReactions) } if err := c.ReactTo("r1", "👎"); err != nil { t.Fatalf("replace ReactTo: %v", err) } - if len(c.session.Reactions) != 1 || c.session.PositiveReactions != 0 || c.session.NegativeReactions != 1 { - t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.session.Reactions, c.session.PositiveReactions, c.session.NegativeReactions) + if len(c.history.Reactions) != 1 || c.history.PositiveReactions != 0 || c.history.NegativeReactions != 1 { + t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.history.Reactions, c.history.PositiveReactions, c.history.NegativeReactions) } ctx := c.Context() if got := ctx[len(ctx)-1].Content; !strings.Contains(got, "response r1: negative") { @@ -2214,7 +2214,7 @@ func TestReactionTargetsResponseAndReplaces(t *testing.T) { func TestReactionPersistsAndRestores(t *testing.T) { path := filepath.Join(t.TempDir(), "session.json") - s := &Session{ + s := &History{ messages: []backend.Message{{Role: "assistant", ID: "r1", Content: "answer"}}, Reactions: []Reaction{{ID: "x1", ResponseID: "r1", Emoji: "🚀", Category: "excellent", CreatedAt: time.Now()}}, PositiveReactions: 1, @@ -2226,7 +2226,7 @@ func TestReactionPersistsAndRestores(t *testing.T) { if err != nil { t.Fatal(err) } - restored := RestoreSession(ps) + restored := RestoreHistory(ps) if len(restored.Reactions) != 1 || restored.Reactions[0].ResponseID != "r1" || restored.PositiveReactions != 1 { t.Fatalf("restored = %+v positive=%d", restored.Reactions, restored.PositiveReactions) } @@ -2238,7 +2238,7 @@ func TestRestoreSession_GoalFromFirstUserMessage(t *testing.T) { {Role: "user", Content: "the real goal"}, {Role: "user", Content: "second user msg"}, } - s := RestoreSession(&PersistedSession{Messages: msgs}) + s := RestoreHistory(&PersistedSession{Messages: msgs}) if s.goal != "the real goal" { t.Errorf("goal = %q; want %q", s.goal, "the real goal") } diff --git a/agent/session.go b/agent/session.go index b1226a9..ca1c23f 100644 --- a/agent/session.go +++ b/agent/session.go @@ -107,7 +107,7 @@ func (ts *TaskState) render() string { } // saveTo writes the full message history to path as JSON. -func (s *Session) saveTo(path, id, agentName, cwd string) error { +func (s *History) saveTo(path, id, agentName, cwd string) error { return s.saveToFull(path, id, agentName, "", "", cwd, "") } @@ -129,7 +129,7 @@ func sanitizeMessages(msgs []backend.Message) []backend.Message { return out } -func (s *Session) saveToFull(path, id, agentName, backendName, modelName, cwd, remote string) error { +func (s *History) saveToFull(path, id, agentName, backendName, modelName, cwd, remote string) error { ps := PersistedSession{ ID: id, Agent: agentName, @@ -176,9 +176,9 @@ func LoadPersistedSession(path string) (*PersistedSession, error) { return &ps, nil } -// RestoreSession reconstructs a Session from a persisted message list. -func RestoreSession(ps *PersistedSession) *Session { - s := &Session{ +// RestoreHistory reconstructs a Session from a persisted message list. +func RestoreHistory(ps *PersistedSession) *History { + s := &History{ messages: ps.Messages, TaskState: ps.TaskState, TotalInputTokens: ps.TotalInputTokens, @@ -207,8 +207,8 @@ func RestoreSession(ps *PersistedSession) *Session { return s } -// Session is an ephemeral in-memory state backend. -type Session struct { +// History is an ephemeral in-memory state backend. +type History struct { goal string messages []backend.Message TaskState *TaskState @@ -233,17 +233,17 @@ type Session struct { Reactions []Reaction } -// newSession creates a new empty Session. The caller is responsible for +// newHistory creates a new empty Session. The caller is responsible for // appending the initial user message via appendUserMessage. -func newSession(goal string) *Session { - return &Session{goal: goal} +func newHistory(goal string) *History { + return &History{goal: goal} } // Checkpoint forks the session: returns a new Session that inherits the given // TaskState but starts with a clean message history. This enables narrow-context // sub-agents that know what to do without inheriting all parent message noise. -func (s *Session) Checkpoint(ts TaskState) *Session { - child := &Session{ +func (s *History) Checkpoint(ts TaskState) *History { + child := &History{ goal: ts.Objective, TaskState: &ts, } @@ -255,7 +255,7 @@ func (s *Session) Checkpoint(ts TaskState) *Session { return child } -func (s *Session) history() []backend.Message { +func (s *History) history() []backend.Message { if len(s.Reactions) == 0 { return s.messages } @@ -270,15 +270,15 @@ func (s *Session) history() []backend.Message { return out } -func (s *Session) taskState() *TaskState { +func (s *History) taskState() *TaskState { return s.TaskState } -func (s *Session) updateTaskState(ts TaskState) { +func (s *History) updateTaskState(ts TaskState) { s.TaskState = &ts } -func (s *Session) addUsage(u backend.Usage, estimated bool) { +func (s *History) addUsage(u backend.Usage, estimated bool) { s.TotalInputTokens += u.InputTokens s.TotalCachedInputTokens += u.CachedInputTokens s.TotalCacheCreationTokens += u.CacheCreationTokens @@ -294,7 +294,7 @@ func (s *Session) addUsage(u backend.Usage, estimated bool) { } } -func (s *Session) recomputeReactionCounts() { +func (s *History) recomputeReactionCounts() { s.PositiveReactions = 0 s.NegativeReactions = 0 for _, r := range s.Reactions { @@ -307,7 +307,7 @@ func (s *Session) recomputeReactionCounts() { } } -func (s *Session) resetTurnAccumulators() { +func (s *History) resetTurnAccumulators() { s.turnInputTokens = 0 s.turnCachedTokens = 0 s.turnCreationTokens = 0 @@ -318,7 +318,7 @@ func (s *Session) resetTurnAccumulators() { // recordTurnCost computes and stores the last turn's cost, adding it to the // session total. model is used for the pricing-table fallback when the backend // did not report cost directly. -func (s *Session) recordTurnCost(model string) { +func (s *History) recordTurnCost(model string) { var cost float64 if s.turnCostUSD > 0 { cost = s.turnCostUSD @@ -334,7 +334,7 @@ func (s *Session) recordTurnCost(model string) { s.SessionCostUSD += cost } -func (s *Session) update(assistant backend.Message, results []toolResult) { +func (s *History) update(assistant backend.Message, results []toolResult) { s.messages = append(s.messages, assistant) for _, r := range results { s.messages = append(s.messages, backend.Message{ @@ -371,7 +371,7 @@ func llmSummarizeToolResult(ctx context.Context, b backend.Backend, name, conten // removeCancelledToolResults filters out tool results that were cancelled due // to interrupt, keeping completed work. Also removes the corresponding tool // calls from assistant messages to maintain a valid message sequence. -func (s *Session) removeCancelledToolResults() { +func (s *History) removeCancelledToolResults() { // First pass: collect cancelled tool call IDs cancelled := make(map[string]bool) for _, m := range s.messages { @@ -415,14 +415,14 @@ func isCancelledToolResult(content string) bool { // PreCompactionSnapshot returns a copy of the current messages for persistence // before compaction. Call this before compact(). -func (s *Session) PreCompactionSnapshot() []backend.Message { +func (s *History) PreCompactionSnapshot() []backend.Message { return slices.Clone(s.messages) } // Compact summarizes the conversation via an LLM call, replacing the history // with system messages + preserved user messages + a structured summary. // Returns (n compacted, summary text, error); n==0 means nothing to compact. -func (s *Session) compact(ctx context.Context, b backend.Backend) (int, string, error) { +func (s *History) compact(ctx context.Context, b backend.Backend) (int, string, error) { if len(s.messages) <= hotTailSize+warmIndexSize { return 0, "", nil } @@ -618,7 +618,7 @@ func flattenToolMessages(messages []backend.Message) []backend.Message { return out } -func (s *Session) appendUserMessage(content string) { +func (s *History) appendUserMessage(content string) { s.messages = append(s.messages, backend.Message{Role: "user", Content: content}) } @@ -646,7 +646,7 @@ func cloneMessages(msgs []backend.Message) []backend.Message { } // estimateTokens returns a rough token count (~4 chars per token). -func (s *Session) estimateTokens() int { +func (s *History) estimateTokens() int { chars := 0 for _, m := range s.messages { chars += len(m.Content) @@ -659,7 +659,7 @@ func (s *Session) estimateTokens() int { // stripColdResults summarizes large tool-result messages outside the hot tail // using an LLM call. Messages in the last hotTailSize slots are left verbatim. -func (s *Session) stripColdResults(ctx context.Context, b backend.Backend) { +func (s *History) stripColdResults(ctx context.Context, b backend.Backend) { hot := len(s.messages) - hotTailSize for i := range s.messages { if i >= hot { @@ -673,12 +673,12 @@ func (s *Session) stripColdResults(ctx context.Context, b backend.Backend) { } // stripCold implements the state interface. -func (s *Session) stripCold(ctx context.Context, b backend.Backend) { +func (s *History) stripCold(ctx context.Context, b backend.Backend) { s.stripColdResults(ctx, b) } // contextDebug returns a multi-line breakdown of the history. -func (s *Session) contextDebug() string { +func (s *History) contextDebug() string { var sb strings.Builder sb.WriteString(fmt.Sprintf("=== %d messages ===\n", len(s.messages))) for i, m := range s.messages { diff --git a/agent/usage_log.go b/agent/usage_log.go index 5086b9d..b9e9cbc 100644 --- a/agent/usage_log.go +++ b/agent/usage_log.go @@ -32,7 +32,7 @@ func usageLogPath() string { } // appendUsageLog writes a single usage entry to the append-only JSONL log. -func appendUsageLog(sessionID, backendName, model string, sess *Session) { +func appendUsageLog(sessionID, backendName, model string, sess *History) { path := usageLogPath() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return