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