474 lines
14 KiB
Go
474 lines
14 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"ollie/cmd/olliesrv/internal/backend"
|
|
"ollie/paths"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
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 Session. 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// llmSummarizeToolResult uses the LLM to produce a concise summary of a tool result.
|
|
func llmSummarizeToolResult(ctx context.Context, b backend.Backend, name, content string) string {
|
|
prompt := fmt.Sprintf("Summarize this tool result from %q in 1-3 sentences, preserving key facts, file paths, values, and findings:\n\n%s", name, content)
|
|
ch, err := b.ChatStream(ctx, []backend.Message{{Role: "user", Content: prompt}}, nil, backend.GenerationParams{})
|
|
if err != nil {
|
|
return content
|
|
}
|
|
var sb strings.Builder
|
|
for ev := range ch {
|
|
if ev.Content != "" {
|
|
sb.WriteString(ev.Content)
|
|
}
|
|
if ev.Done {
|
|
break
|
|
}
|
|
}
|
|
if s := strings.TrimSpace(sb.String()); s != "" {
|
|
return fmt.Sprintf("[summary of %s result]: %s", name, s)
|
|
}
|
|
return content
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (s *History) 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 *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
|
|
}
|
|
|
|
// stripColdResults summarizes large tool-result messages outside the hot tail
|
|
// using an LLM call. Messages in the last hotTailSize slots are left verbatim.
|
|
func (s *History) stripColdResults(ctx context.Context, b backend.Backend) {
|
|
hot := len(s.messages) - hotTailSize
|
|
for i := range s.messages {
|
|
if i >= hot {
|
|
break
|
|
}
|
|
m := &s.messages[i]
|
|
if m.Role == "tool" && len(m.Content) > 200 {
|
|
m.Content = llmSummarizeToolResult(ctx, b, m.ToolCallID, m.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// stripCold implements the state interface.
|
|
func (s *History) stripCold(ctx context.Context, b backend.Backend) {
|
|
s.stripColdResults(ctx, b)
|
|
}
|
|
|
|
// resolveCompactionModel returns the model to use for compaction.
|
|
// Priority: agent config > OLLIE_COMPACTION_MODEL env > per-backend default from models.yaml > session's current model.
|
|
// Returns "" if no override is configured (use the session's current model).
|
|
func resolveCompactionModel(cfgModel string, b backend.Backend) string {
|
|
if cfgModel != "" {
|
|
return cfgModel
|
|
}
|
|
if env := os.Getenv("OLLIE_COMPACTION_MODEL"); env != "" {
|
|
return env
|
|
}
|
|
cfg := loadModelsConfig()
|
|
if b != nil {
|
|
if m, ok := cfg.Compaction[b.Name()]; ok {
|
|
return m
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// modelsConfig holds the parsed contents of ~/.config/ollie/models.yaml.
|
|
type modelsConfig struct {
|
|
Compaction map[string]string `yaml:"compaction"`
|
|
Completion struct {
|
|
Model string `yaml:"model"`
|
|
Backend string `yaml:"backend"`
|
|
} `yaml:"completion"`
|
|
}
|
|
|
|
// loadModelsConfig reads and parses the models config file.
|
|
// Returns zero value if the file doesn't exist or is invalid.
|
|
func loadModelsConfig() modelsConfig {
|
|
data, err := os.ReadFile(paths.CfgDir() + "/models.yaml")
|
|
if err != nil {
|
|
return modelsConfig{}
|
|
}
|
|
var cfg modelsConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return modelsConfig{}
|
|
}
|
|
return cfg
|
|
}
|