package agent import ( "context" "encoding/json" "fmt" "os" "strings" "ollie/cmd/olliesrv/internal/backend" "ollie/paths" ) 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, } for i := range s.messages { if s.messages[i].Role == "assistant" && s.messages[i].ID == "" { s.messages[i].ID = paths.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 // 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} } 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) 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 { s.messages = append(s.messages, backend.Message{ Role: "tool", Content: r.Content, ContentBlocks: r.ContentBlocks, ToolCallID: r.ToolCallID, }) } } // 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" { 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 } // 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) { hot := len(s.messages) - hotTailSize if hot < 0 { return } // Collect cold-zone tool results exceeding 200 chars. type item struct { id int idx int // index into s.messages orig 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 { items = append(items, item{id: len(items), idx: i, orig: m.Content}) } } if len(items) == 0 { return } // 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 a JSON object where each key is the id and the value is the summary, for example:\n{\"0\": \"summarized result 0\", \"1\": \"summarized result 1\"}\n\nOnly include keys that were in the input.\n\n") for _, it := range items { fmt.Fprintf(&sb, "---\nid=%d:\n%s\n", it.id, it.orig) } ch, err := b.ChatStream(ctx, []backend.Message{{Role: "user", Content: sb.String()}}, nil, backend.GenerationParams{}) if err != nil { return } var resp strings.Builder for ev := range ch { if ev.Content != "" { resp.WriteString(ev.Content) } if ev.Done { break } } summaries := make(map[string]string) json.Unmarshal([]byte(resp.String()), &summaries) // Apply summaries back into history. for _, it := range items { key := fmt.Sprintf("%d", it.id) if summary, ok := summaries[key]; ok && summary != "" { s.messages[it.idx].Content = summary } } } // resolveCompactionModel returns the model to use for compaction. // Priority: agent config > OLLIE_COMPACTION_MODEL > backends.conf compactionModel > backends.conf model > current model. func resolveCompactionModel(cfgModel string, b backend.Backend) string { if cfgModel != "" { return cfgModel } if env := os.Getenv("OLLIE_COMPACTION_MODEL"); env != "" { return env } if b != nil { if model := backend.CompactionModel(b.Name()); model != "" { return model } return b.Model() } return "" }