ollie/cmd/olliesrv/internal/agent/history.go

594 lines
17 KiB
Go

package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"ollie/cmd/olliesrv/internal/backend"
"ollie/util"
)
const (
compactionPrompt = `You are performing a CONTEXT CHECKPOINT COMPACTION. Produce a JSON object summarizing the current task state. Output ONLY valid JSON matching this schema:
{
"objective": "the user's overall goal",
"plan_step": "what step you are currently on",
"constraints": ["any constraints or user preferences"],
"last_action": "brief: last tool/action + outcome",
"open_questions": ["unresolved questions"],
"next_decision": "what to do next"
}
Be concise. Capture what another LLM needs to seamlessly continue.`
// hotTailSize is the number of most recent messages kept verbatim (hot zone).
hotTailSize = 8
// warmIndexSize is the number of messages preceding the hot zone
// summarized as a brief decision index (warm zone).
warmIndexSize = 10
)
// RestoreHistoryFromMessages reconstructs a History from just the message list.
// Used for multi-agent session restore where each agent has separate messages.
func RestoreHistoryFromMessages(messages []backend.Message) *History {
s := &History{
messages: messages,
summaryCache: make(map[string]string),
}
for i := range s.messages {
if s.messages[i].Role == "assistant" && s.messages[i].ID == "" {
s.messages[i].ID = util.NewUUID()
}
if s.messages[i].Role == "tool" && s.messages[i].ID == "" {
if s.messages[i].ToolCallID != "" {
s.messages[i].ID = toolMessageID(s.messages[i])
} else {
s.messages[i].ID = util.NewUUID()
}
}
}
for _, m := range messages {
if m.Role == "user" {
s.goal = m.Content
break
}
}
return s
}
// History is an ephemeral in-memory state backend.
type History struct {
goal string
messages []backend.Message
// summaryCache reuses validated summaries for identical tool-result content
// during the lifetime of this agent. SummaryApplied persists per-message
// state across session restore; this cache avoids new LLM calls for repeats.
summaryCache map[string]string
// Cumulative usage tracking.
TotalInputTokens int
TotalCachedInputTokens int
TotalCacheCreationTokens int
TotalOutputTokens int
TotalRequests int
Estimated bool // true if any usage was estimated rather than reported by the backend
LastTurnCostUSD float64 // cost of the most recently completed turn in USD
SessionCostUSD float64 // cumulative cost of all turns in this session in USD
// per-turn accumulators; reset at the start of each turn
turnInputTokens int
turnCachedTokens int
turnCreationTokens int
turnOutputTokens int
turnCostUSD float64 // >0 when backend reported cost directly (e.g. OpenRouter)
}
// newHistory creates a new empty History. The caller is responsible for
// appending the initial user message via appendUserMessage.
func newHistory(goal string) *History {
return &History{goal: goal, summaryCache: make(map[string]string)}
}
func toolMessageID(m backend.Message) string {
if m.ToolCallID != "" {
return "tool:" + m.ToolCallID
}
h := sha256.Sum256([]byte(m.Content))
return "tool:content:" + hex.EncodeToString(h[:])
}
const coldSummaryCacheVersion = "v2"
func toolSummaryHash(m backend.Message) string {
input := coldSummaryCacheVersion + "\x00" + m.ToolName + "\x00" + normalizeSummaryCacheContent(m.Content)
h := sha256.Sum256([]byte(input))
return "cold-summary:" + coldSummaryCacheVersion + ":" + hex.EncodeToString(h[:])
}
func normalizeSummaryCacheContent(content string) string {
if looksStructured(content) {
return content
}
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
normalized := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
line = strings.TrimRight(line, " \t")
if strings.TrimSpace(line) == "" {
if blank {
continue
}
blank = true
} else {
blank = false
}
normalized = append(normalized, line)
}
return strings.TrimSpace(strings.Join(normalized, "\n"))
}
func (s *History) history() []backend.Message {
return s.messages
}
func (s *History) addUsage(u backend.Usage, estimated bool) {
s.TotalInputTokens += u.InputTokens
s.TotalCachedInputTokens += u.CachedInputTokens
s.TotalCacheCreationTokens += u.CacheCreationTokens
s.TotalOutputTokens += u.OutputTokens
s.TotalRequests++
s.turnInputTokens += u.InputTokens
s.turnCachedTokens += u.CachedInputTokens
s.turnCreationTokens += u.CacheCreationTokens
s.turnOutputTokens += u.OutputTokens
s.turnCostUSD += u.CostUSD
if estimated {
s.Estimated = true
}
}
func (s *History) cacheHitRatio() float64 {
total := s.TotalInputTokens + s.TotalCachedInputTokens
if total == 0 {
return 0
}
return float64(s.TotalCachedInputTokens) / float64(total)
}
func (s *History) resetTurnAccumulators() {
s.turnInputTokens = 0
s.turnCachedTokens = 0
s.turnCreationTokens = 0
s.turnOutputTokens = 0
s.turnCostUSD = 0
}
// 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 *History) recordTurnCost(model string) {
var cost float64
if s.turnCostUSD > 0 {
cost = s.turnCostUSD
} else {
cost = computeCostUSD(model, backend.Usage{
InputTokens: s.turnInputTokens,
CachedInputTokens: s.turnCachedTokens,
CacheCreationTokens: s.turnCreationTokens,
OutputTokens: s.turnOutputTokens,
})
}
s.LastTurnCostUSD = cost
s.SessionCostUSD += cost
}
func (s *History) update(assistant backend.Message, results []toolResult) {
s.messages = append(s.messages, assistant)
for _, r := range results {
m := backend.Message{
Role: "tool",
Content: r.Content,
ContentBlocks: r.ContentBlocks,
ToolCallID: r.ToolCallID,
ToolName: r.Name,
}
if r.ToolCallID != "" {
m.ID = toolMessageID(m)
} else {
m.ID = util.NewUUID()
}
s.messages = append(s.messages, m)
}
}
// 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 *History) removeCancelledToolResults() {
// First pass: collect cancelled tool call IDs
cancelled := make(map[string]bool)
for _, m := range s.messages {
if m.Role == "tool" && isCancelledToolResult(m.Content) {
cancelled[m.ToolCallID] = true
}
}
if len(cancelled) == 0 {
return
}
// Second pass: filter messages and prune tool calls from assistant messages
filtered := s.messages[:0]
for _, m := range s.messages {
if m.Role == "tool" && cancelled[m.ToolCallID] {
continue
}
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// Remove cancelled tool calls from this assistant message
kept := m.ToolCalls[:0]
for _, tc := range m.ToolCalls {
if !cancelled[tc.ID] {
kept = append(kept, tc)
}
}
if len(kept) == 0 && m.Content == "" {
// All tool calls cancelled and no text content - skip message
continue
}
m.ToolCalls = kept
}
filtered = append(filtered, m)
}
s.messages = filtered
}
func isCancelledToolResult(content string) bool {
return strings.Contains(content, `"status":"cancelled"`) ||
strings.Contains(content, "tool execution interrupted by user")
}
// 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 *History) compact(ctx context.Context, b backend.Backend) (int, string, error) {
if len(s.messages) <= hotTailSize+warmIndexSize {
return 0, "", nil
}
// Flatten tool calls into plain text so the compaction request
// doesn't need tool schemas.
flattened := flattenToolMessages(s.messages)
flattened = append(flattened, backend.Message{
Role: "user",
Content: compactionPrompt,
})
ch, err := b.ChatStream(ctx, flattened, nil, backend.GenerationParams{})
if err != nil {
return 0, "", fmt.Errorf("compact: %w", err)
}
var summary strings.Builder
for ev := range ch {
if ev.Content != "" {
summary.WriteString(ev.Content)
}
if ev.Done {
break
}
}
summaryText := strings.TrimSpace(summary.String())
if summaryText == "" {
return 0, "", fmt.Errorf("compact: empty summary")
}
beforeCount := len(s.messages)
s.messages = buildCompactedHistory(summaryText, s.messages)
return beforeCount - len(s.messages), summaryText, nil
}
// buildCompactedHistory constructs a three-zone post-compaction message list:
// - Cold: compaction summary (everything older than warm+hot)
// - Warm: brief index of recent decisions/actions (warmIndexSize messages)
// - Hot: last hotTailSize messages verbatim
func buildCompactedHistory(summary string, allMessages []backend.Message) []backend.Message {
total := len(allMessages)
// Determine zone boundaries.
hotStart := total - hotTailSize
if hotStart < 0 {
hotStart = 0
}
// If hotStart lands on a tool message, walk backward to include the
// preceding assistant message with tool_calls. Without this, the hot
// zone starts with an orphaned tool result which violates the OpenAI
// API constraint that tool messages must follow an assistant message
// containing the corresponding tool_calls.
for hotStart > 0 && allMessages[hotStart].Role == "tool" {
hotStart--
}
warmStart := hotStart - warmIndexSize
if warmStart < 0 {
warmStart = 0
}
// Cold zone: compaction summary.
cold := backend.Message{
Role: "user",
Content: "[compacted context]\n" + summary,
}
var out []backend.Message
out = append(out, cold)
// Warm zone: one-line summaries of decisions in the warm window.
warmSlice := allMessages[warmStart:hotStart]
if len(warmSlice) > 0 {
var sb strings.Builder
sb.WriteString("[warm zone — recent decisions]\n")
for _, m := range warmSlice {
line := summarizeMessage(m)
if line != "" {
sb.WriteString("- ")
sb.WriteString(line)
sb.WriteByte('\n')
}
}
out = append(out, backend.Message{Role: "user", Content: sb.String()})
}
// Hot zone: verbatim recent messages.
out = append(out, allMessages[hotStart:]...)
return out
}
// summarizeMessage produces a one-line summary of a message for the warm index.
func summarizeMessage(m backend.Message) string {
switch m.Role {
case "assistant":
if len(m.ToolCalls) > 0 {
names := make([]string, len(m.ToolCalls))
for i, tc := range m.ToolCalls {
names[i] = tc.Name
}
return "called: " + strings.Join(names, ", ")
}
text := m.Content
if len(text) > 120 {
text = text[:120] + "…"
}
return "said: " + text
case "user":
text := m.Content
if len(text) > 120 {
text = text[:120] + "…"
}
return "user: " + text
case "tool":
text := m.Content
if len(text) > 80 {
text = text[:80] + "…"
}
return "result(" + m.ToolCallID + "): " + text
}
return ""
}
// flattenToolMessages converts tool call/result sequences into plain text
// so the compaction request doesn't include tool-specific structures that
// the API may reject when no tools are defined.
func flattenToolMessages(messages []backend.Message) []backend.Message {
out := make([]backend.Message, 0, len(messages))
for _, m := range messages {
switch {
case m.Role == "assistant" && len(m.ToolCalls) > 0:
var sb strings.Builder
if m.Content != "" {
sb.WriteString(m.Content)
sb.WriteString("\n\n")
}
for _, tc := range m.ToolCalls {
fmt.Fprintf(&sb, "[Tool call: %s(%s)]\n", tc.Name, string(tc.Arguments))
}
out = append(out, backend.Message{Role: "assistant", Content: sb.String()})
case m.Role == "tool":
text := m.Content
if len(text) > 4000 {
text = text[:4000] + "..."
}
out = append(out, backend.Message{
Role: "user",
Content: fmt.Sprintf("[Tool result for %s]:\n%s", m.ToolCallID, text),
})
default:
out = append(out, m)
}
}
return out
}
// injectPreamble ensures the preamble system message is at history position 0.
// If a system message already exists there, its Content is replaced; otherwise
// one is prepended.
func (s *History) injectPreamble(preamble string) {
if preamble == "" {
return
}
msg := backend.Message{Role: "system", Content: preamble}
if len(s.messages) > 0 && s.messages[0].Role == "system" {
if s.messages[0].Content == preamble {
return
}
s.messages[0] = msg
} else {
s.messages = append([]backend.Message{msg}, s.messages...)
}
}
func (s *History) appendUserMessage(content string) {
s.messages = append(s.messages, backend.Message{Role: "user", Content: content})
}
// estimateTokens returns a rough token count (~4 chars per token).
func (s *History) estimateTokens() int {
chars := 0
for _, m := range s.messages {
chars += len(m.Content)
for _, tc := range m.ToolCalls {
chars += len(tc.Name) + len(tc.Arguments)
}
}
return chars / 4
}
const (
maxColdSummariesPerCall = 8
minColdSummaries = 3
minColdSummaryTokens = 32_000
)
func (s *History) pendingColdSummaryStats() (count, tokens int) {
hot := len(s.messages) - hotTailSize
if hot <= 0 {
return 0, 0
}
for i := 0; i < hot; i++ {
m := &s.messages[i]
if m.Role != "tool" || len(m.Content) <= 200 || m.SummaryApplied {
continue
}
if s.summaryCache != nil {
if _, ok := s.summaryCache[toolSummaryHash(*m)]; ok {
continue
}
}
count++
tokens += len(m.Content) / 4
}
return count, tokens
}
// stripCold summarizes large tool-result messages outside the hot tail
// using a single batched LLM call. Messages in the last hotTailSize slots
// are left verbatim.
func (s *History) stripCold(ctx context.Context, b backend.Backend) (backend.Usage, bool) {
if s.summaryCache == nil {
s.summaryCache = make(map[string]string)
}
hot := len(s.messages) - hotTailSize
if hot < 0 {
return backend.Usage{}, false
}
// Collect cold-zone tool results exceeding 200 chars.
type item struct {
idx int // index into s.messages
orig string
messageID string
hash string
}
var items []item
for i := range s.messages {
if i >= hot {
break
}
m := &s.messages[i]
if m.Role == "tool" && (len(m.Content) > 200 || strings.TrimSpace(m.Content) == "") {
id := m.ID
if id == "" {
if m.ToolCallID != "" {
id = toolMessageID(*m)
} else {
id = util.NewUUID()
}
m.ID = id
}
if !m.SummaryApplied {
if summary, ok := localColdSummary(m.Content); ok {
hash := toolSummaryHash(*m)
s.summaryCache[hash] = summary
m.Content = summary
m.SummaryApplied = true
continue
}
if summary, ok := s.summaryCache[toolSummaryHash(*m)]; ok {
m.Content = summary
m.SummaryApplied = true
continue
}
items = append(items, item{idx: i, orig: m.Content, messageID: id, hash: toolSummaryHash(*m)})
if len(items) >= maxColdSummariesPerCall {
break
}
}
}
}
if len(items) == 0 {
return backend.Usage{}, false
}
// Build a single prompt listing each item.
var sb strings.Builder
sb.WriteString("Compress each of the following tool results to 1-3 sentences preserving key facts, file paths, values, and findings.\n\nReturn ONLY a valid JSON object. Do not use Markdown fences, prose, or any text outside the JSON object. Each key must be exactly one of the supplied message IDs and each value must be a string summary, for example:\n{\"tool:call-123\": \"summarized result\", \"tool:call-456\": \"another summary\"}\n\nOnly include keys that were in the input.\n\n")
for _, it := range items {
fmt.Fprintf(&sb, "---\nid=%s:\n%s\n", it.messageID, it.orig)
}
ch, err := b.ChatStream(ctx, []backend.Message{{Role: "user", Content: sb.String()}}, nil, backend.GenerationParams{})
if err != nil {
return backend.Usage{}, false
}
var resp strings.Builder
var usage backend.Usage
var reported bool
for ev := range ch {
if ev.Content != "" {
resp.WriteString(ev.Content)
}
if ev.Done {
usage = ev.Usage
reported = true
break
}
}
if !reported {
usage.InputTokens = len(sb.String()) / 4
usage.OutputTokens = resp.Len() / 4
}
estimated := !reported
summaries, err := parseColdSummaries(resp.String())
if err != nil {
return usage, estimated
}
// Apply summaries back into history.
for _, it := range items {
if summary, ok := summaries[it.messageID]; ok && summary != "" {
s.summaryCache[it.hash] = summary
s.messages[it.idx].Content = summary
s.messages[it.idx].SummaryApplied = true
}
}
return usage, estimated
}
// resolveCompactionModel returns the model to use for compaction.
// Priority: agent config > backends.conf compactionModel > backends.conf model > current model.
func resolveCompactionModel(cfgModel string, b backend.Backend) string {
if cfgModel != "" {
return cfgModel
}
if b != nil {
if model := backend.CompactionModel(b.Name()); model != "" {
return model
}
return b.Model()
}
return ""
}