This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/pkg/agent/session.go

263 lines
7.7 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"ollie/pkg/backend"
)
const (
compactionPrompt = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`
compactionSummaryPrefix = `Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work.
Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:`
// compactionUserTokenBudget is the approximate token budget for preserving
// recent user messages in the compacted history.
compactionUserTokenBudget = 20000
)
// PersistedSession is the on-disk format for a saved session.
type PersistedSession struct {
ID string `json:"id"`
Agent string `json:"agent,omitempty"`
Messages []backend.Message `json:"messages"`
}
// SaveTo writes the full message history to path as JSON.
func (s *Session) saveTo(path, id, agentName string) error {
ps := PersistedSession{
ID: id,
Agent: agentName,
Messages: s.messages,
}
data, err := json.Marshal(ps)
if err != nil {
return fmt.Errorf("session save: %w", err)
}
return os.WriteFile(path, data, 0600)
}
// RestoreSession reconstructs a Session from a persisted message list.
func RestoreSession(messages []backend.Message) *Session {
s := &Session{messages: messages}
for _, m := range messages {
if m.Role == "user" {
s.goal = m.Content
break
}
}
return s
}
// Session is an ephemeral in-memory state backend.
type Session struct {
goal string
messages []backend.Message
// Cumulative usage tracking.
TotalInputTokens int
TotalOutputTokens int
TotalRequests int
Estimated bool // true if any usage was estimated rather than reported by the backend
}
// newSession 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 (s *Session) history() []backend.Message {
return s.messages
}
func (s *Session) addUsage(u backend.Usage, estimated bool) {
s.TotalInputTokens += u.InputTokens
s.TotalOutputTokens += u.OutputTokens
s.TotalRequests++
if estimated {
s.Estimated = true
}
}
func (s *Session) 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,
ToolCallID: r.ToolCallID,
})
}
}
// PreCompactionSnapshot returns a copy of the current messages for persistence
// before compaction. Call this before compact().
func (s *Session) 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) {
if len(s.messages) <= 4 {
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")
}
// Rebuild: system messages + preserved user messages + summary.
beforeCount := len(s.messages)
s.messages = buildCompactedHistory(s.messages, summaryText)
return beforeCount - len(s.messages), summaryText, nil
}
// buildCompactedHistory constructs the post-compaction message list:
// system messages + recent user messages (within token budget) + summary.
func buildCompactedHistory(history []backend.Message, summary string) []backend.Message {
var result []backend.Message
for _, m := range history {
if m.Role == "system" {
result = append(result, m)
}
}
result = append(result, selectUserMessages(history, compactionUserTokenBudget)...)
result = append(result, backend.Message{
Role: "user",
Content: compactionSummaryPrefix + "\n" + strings.TrimSpace(summary),
})
return result
}
// selectUserMessages picks recent user messages (newest first) up to a token budget.
func selectUserMessages(history []backend.Message, tokenBudget int) []backend.Message {
var selected []backend.Message
remaining := tokenBudget
for i := len(history) - 1; i >= 0; i-- {
m := history[i]
if m.Role != "user" {
continue
}
text := strings.TrimSpace(m.Content)
if text == "" || strings.HasPrefix(text, compactionSummaryPrefix) {
continue
}
tokens := (len(text) + 3) / 4
if tokens > remaining {
break
}
remaining -= tokens
selected = append(selected, backend.Message{Role: "user", Content: text})
}
slices.Reverse(selected)
return selected
}
// 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
}
func (s *Session) 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 *Session) 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
}
// contextDebug returns a multi-line breakdown of the history.
func (s *Session) contextDebug() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("=== %d messages ===\n", len(s.messages)))
for i, m := range s.messages {
preview := m.Content
if len(preview) > 80 {
preview = preview[:80] + "..."
}
chars := len(m.Content)
for _, tc := range m.ToolCalls {
chars += len(tc.Name) + len(tc.Arguments)
}
sb.WriteString(fmt.Sprintf(" [%d] role=%-10s chars=%-6d %q\n", i, m.Role, chars, preview))
}
return sb.String()
}