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

442 lines
13 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"ollie/pkg/backend"
)
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.`
compactionSummaryPrefix = `Context was compacted. Here is the structured task state from the prior context, followed by the most recent messages for evidential context:`
// recentTailSize is the number of messages to preserve after compaction.
recentTailSize = 6
)
// 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"`
TaskState *TaskState `json:"taskState,omitempty"`
}
// TaskState is a compact structured overlay that summarizes the agent's
// current position in a task. Injected at the top of every turn so the
// model doesn't rely on full history recall.
type TaskState struct {
Objective string `json:"objective"`
PlanStep string `json:"plan_step"`
Constraints []string `json:"constraints,omitempty"`
LastAction string `json:"last_action"`
OpenQuestions []string `json:"open_questions,omitempty"`
NextDecision string `json:"next_decision"`
}
// render produces a compact text representation for injection into the message history.
func (ts *TaskState) render() string {
if ts.Objective == "" {
return ""
}
var sb strings.Builder
sb.WriteString("[task state]\n")
sb.WriteString("objective: " + ts.Objective + "\n")
if ts.PlanStep != "" {
sb.WriteString("plan_step: " + ts.PlanStep + "\n")
}
if len(ts.Constraints) > 0 {
sb.WriteString("constraints: " + strings.Join(ts.Constraints, "; ") + "\n")
}
if ts.LastAction != "" {
sb.WriteString("last_action: " + ts.LastAction + "\n")
}
if len(ts.OpenQuestions) > 0 {
sb.WriteString("open_questions: " + strings.Join(ts.OpenQuestions, "; ") + "\n")
}
if ts.NextDecision != "" {
sb.WriteString("next_decision: " + ts.NextDecision + "\n")
}
sb.WriteString("[/task state]")
return sb.String()
}
// 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,
TaskState: s.TaskState,
}
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
TaskState *TaskState
// 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)
}
// 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) recentHistory(n int) []backend.Message {
if n >= len(s.messages) {
return s.messages
}
return s.messages[len(s.messages)-n:]
}
func (s *Session) taskState() *TaskState {
return s.TaskState
}
func (s *Session) updateTaskState(ts TaskState) {
s.TaskState = &ts
}
func (s *Session) 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 *Session) 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 *Session) 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 *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,
})
}
}
// removeCancelledToolResults filters out tool results that were cancelled due
// to interrupt, keeping completed work. Also removes the corresponding tool
// calls from assistant messages to maintain a valid message sequence.
func (s *Session) removeCancelledToolResults() {
// 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")
}
// 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")
}
// Parse structured task state from the model's JSON response.
var ts TaskState
raw := extractJSON(summaryText)
if err := json.Unmarshal([]byte(raw), &ts); err != nil {
// Fallback: treat as unstructured summary if JSON parse fails.
ts = TaskState{Objective: summaryText}
}
s.TaskState = &ts
// Keep the last N messages as evidential context.
tail := s.messages
if len(tail) > recentTailSize {
tail = tail[len(tail)-recentTailSize:]
}
beforeCount := len(s.messages)
s.messages = buildCompactedHistory(ts, tail)
return beforeCount - len(s.messages), summaryText, nil
}
// buildCompactedHistory constructs the post-compaction message list:
// a structured task state header followed by recent messages as evidence.
func buildCompactedHistory(ts TaskState, recentMessages []backend.Message) []backend.Message {
stateJSON, _ := json.Marshal(ts)
header := backend.Message{
Role: "user",
Content: compactionSummaryPrefix + "\n```json\n" + string(stateJSON) + "\n```",
}
return append([]backend.Message{header}, recentMessages...)
}
// extractJSON finds the first JSON object in s (handling markdown fences).
func extractJSON(s string) string {
// Strip markdown code fence if present.
if i := strings.Index(s, "```json"); i >= 0 {
s = s[i+7:]
if j := strings.Index(s, "```"); j >= 0 {
s = s[:j]
}
} else if i := strings.Index(s, "```"); i >= 0 {
s = s[i+3:]
if j := strings.Index(s, "```"); j >= 0 {
s = s[:j]
}
}
// Find first { ... }
start := strings.Index(s, "{")
if start < 0 {
return s
}
end := strings.LastIndex(s, "}")
if end < start {
return s
}
return s[start : end+1]
}
// 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})
}
// cloneMessages returns a deep copy of the message slice.
func cloneMessages(msgs []backend.Message) []backend.Message {
out := make([]backend.Message, len(msgs))
for i, m := range msgs {
out[i] = backend.Message{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
}
if len(m.ToolCalls) > 0 {
out[i].ToolCalls = make([]backend.ToolCall, len(m.ToolCalls))
for j, tc := range m.ToolCalls {
out[i].ToolCalls[j] = backend.ToolCall{
ID: tc.ID,
Name: tc.Name,
Arguments: append(json.RawMessage(nil), tc.Arguments...),
}
}
}
}
return out
}
// 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()
}