Files
2026-05-23 00:16:03 +07:00

157 lines
5.1 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// compactionSummaryPrompt is the structured summarization instruction used by both
// mid-loop compaction and background summarization. Matching OpenClaw TS compaction.ts
// MERGE_SUMMARIES_INSTRUCTIONS + IDENTIFIER_PRESERVATION_INSTRUCTIONS.
const compactionSummaryPrompt = `Summarize this conversation concisely for the AI agent to resume work.
MUST PRESERVE:
- Active tasks and their current status (in-progress, blocked, pending)
- Pending subagent tasks (IDs, labels, statuses) — agent needs to know what is still running
- Pending team task results awaiting delivery (task IDs, assignees, statuses)
- Any "waiting for..." state — do NOT drop expectations of future results
- Batch operation progress (e.g., "5/17 items completed")
- The last thing the user requested and what was being done about it
- Decisions made and their rationale
- TODOs, open questions, and constraints
- Any commitments or follow-ups promised
IDENTIFIER PRESERVATION:
Preserve all opaque identifiers exactly as written (no shortening or reconstruction),
including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.
PRIORITIZE recent context over older history. The agent needs to know
what it was doing, not just what was discussed.
Conversation to summarize:
`
// compactMessagesInPlace summarizes the first ~70% of messages into a condensed
// summary, keeping the last ~30% intact. Operates purely on the local messages
// slice — no session state touched, no locks needed.
// Returns nil on failure (caller keeps original messages).
func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.Message) []providers.Message {
if len(messages) < 6 {
return nil
}
// Resolve keepCount from compaction config (same defaults as maybeSummarize).
keepCount := 4
if l.compactionCfg != nil && l.compactionCfg.KeepLastMessages > 0 {
keepCount = l.compactionCfg.KeepLastMessages
}
// Ensure we keep at least 30% of messages.
if minKeep := len(messages) * 3 / 10; minKeep > keepCount {
keepCount = minKeep
}
splitIdx := len(messages) - keepCount
// Walk backward from splitIdx to find a clean boundary —
// avoid splitting tool_use → tool_result pairs.
for splitIdx > 0 {
m := messages[splitIdx]
if m.Role == "tool" || (m.Role == "assistant" && len(m.ToolCalls) > 0) {
splitIdx--
continue
}
break
}
if splitIdx <= 1 {
return nil
}
// Build summary input (same pattern as maybeSummarize in loop_history.go).
toSummarize := messages[:splitIdx]
var sb strings.Builder
for _, m := range toSummarize {
switch m.Role {
case "user":
fmt.Fprintf(&sb, "user: %s\n", m.Content)
case "assistant":
fmt.Fprintf(&sb, "assistant: %s\n", SanitizeAssistantContent(m.Content))
}
}
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
inTokens := l.estimateSummaryInputTokens(toSummarize)
slog.Info("compact_budget", "agent", l.id, "in_tokens", inTokens, "out_tokens", dynamicSummaryMax(inTokens))
chatReq := providers.ChatRequest{
Messages: []providers.Message{{
Role: "user",
Content: compactionSummaryPrompt + sb.String(),
}},
Model: l.model,
Options: map[string]any{"max_tokens": dynamicSummaryMax(inTokens), "temperature": 0.3},
}
resp, err := l.callInternalLLMWithUsage(sctx, chatReq, "mid-loop-compaction")
if err != nil {
slog.Warn("mid_loop_compaction_failed", "agent", l.id, "error", err)
return nil
}
// Collect MediaRefs from compacted messages (keep up to 30 most recent).
const maxPreservedMediaRefs = 30
var preservedRefs []providers.MediaRef
for i := len(toSummarize) - 1; i >= 0 && len(preservedRefs) < maxPreservedMediaRefs; i-- {
for _, ref := range toSummarize[i].MediaRefs {
preservedRefs = append(preservedRefs, ref)
if len(preservedRefs) >= maxPreservedMediaRefs {
break
}
}
}
summary := providers.Message{
Role: "user",
Content: "[Summary of earlier conversation]\n" + SanitizeAssistantContent(resp.Content),
MediaRefs: preservedRefs,
}
result := make([]providers.Message, 0, 1+keepCount)
result = append(result, summary)
result = append(result, messages[splitIdx:]...)
slog.Info("mid_loop_compacted",
"agent", l.id,
"original_msgs", len(messages),
"summarized", splitIdx,
"kept", len(result))
return result
}
// dynamicSummaryMax returns the output-token budget for a compaction or
// summarization call, scaled to input size. Formula: in/25 (~4% compression),
// clamped to [1024, 8192]. Floor keeps short summaries coherent; cap prevents
// runaway output billing on pathological inputs.
func dynamicSummaryMax(inputTokens int) int {
out := min(max(inputTokens/25, 1024), 8192)
return out
}
// estimateSummaryInputTokens returns a best-effort input-token count. Prefers
// TokenCounter when attached; else rune/3 fallback (~±15% for UTF-8).
func (l *Loop) estimateSummaryInputTokens(messages []providers.Message) int {
if l.tokenCounter != nil {
return l.tokenCounter.CountMessages(l.model, messages)
}
total := 0
for _, m := range messages {
total += len([]rune(m.Content)) / 3
}
return total
}