feat: add structured ContextCompressor with ContextManager integration
Implement 6-phase context compression as a pluggable ContextManager: - Phase 1: Prune old tool results with placeholders - Phase 2-3: Protect head (system prompt) and tail (token budget) - Phase 4: Generate structured summary prompt (LLM-agnostic) - Phase 5: Assemble head + summary + tail - Phase 6: Sanitize orphaned tool call/result pairs Registered as "structured" via RegisterContextManager factory. Activate with: agents.defaults.context_manager = "structured" Zero breaking change — defaults to "legacy" if unconfigured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84e42d6904
commit
6968cf385e
2 changed files with 495 additions and 0 deletions
356
pkg/agent/context_compressor.go
Normal file
356
pkg/agent/context_compressor.go
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// ContextCompressor implements a 6-phase context compression algorithm
|
||||
// inspired by Hermes Agent's context_compressor.py.
|
||||
//
|
||||
// Phases:
|
||||
// 1. Prune — replace old tool results with placeholders (no LLM)
|
||||
// 2. Protect head — keep first N messages (system prompt + setup)
|
||||
// 3. Protect tail — keep last messages by token budget
|
||||
// 4. Summarize — generate structured summary (done externally by caller)
|
||||
// 5. Assemble — combine head + summary + tail
|
||||
// 6. Sanitize — fix orphaned tool_call/result pairs
|
||||
type ContextCompressor struct {
|
||||
mu sync.Mutex
|
||||
|
||||
contextLength int // total context window in tokens
|
||||
thresholdTokens int // trigger compression at this token count
|
||||
|
||||
// Protection boundaries
|
||||
protectFirstN int // head messages to never compress (default: 3)
|
||||
protectLastN int // fallback tail protection (default: 20)
|
||||
|
||||
// Compression state
|
||||
compressionCount int
|
||||
previousSummary string // iterative summary from last compression
|
||||
|
||||
// Token tracking
|
||||
lastPromptTokens int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultThresholdPercent = 50 // compress at 50% of context
|
||||
defaultProtectFirstN = 3
|
||||
defaultProtectLastN = 20
|
||||
charsPerToken = 4 // rough estimate
|
||||
maxPrunedContentLen = 200
|
||||
)
|
||||
|
||||
// CompressorOption configures the compressor.
|
||||
type CompressorOption func(*ContextCompressor)
|
||||
|
||||
// WithThresholdPercent sets when compression triggers (default: 50%).
|
||||
func WithThresholdPercent(pct int) CompressorOption {
|
||||
return func(cc *ContextCompressor) {
|
||||
cc.thresholdTokens = cc.contextLength * pct / 100
|
||||
}
|
||||
}
|
||||
|
||||
// WithProtectFirstN sets how many head messages to protect.
|
||||
func WithProtectFirstN(n int) CompressorOption {
|
||||
return func(cc *ContextCompressor) { cc.protectFirstN = n }
|
||||
}
|
||||
|
||||
// WithProtectLastN sets the fallback tail protection count.
|
||||
func WithProtectLastN(n int) CompressorOption {
|
||||
return func(cc *ContextCompressor) { cc.protectLastN = n }
|
||||
}
|
||||
|
||||
// NewContextCompressor creates a compressor for the given context window.
|
||||
func NewContextCompressor(contextLength int, opts ...CompressorOption) *ContextCompressor {
|
||||
cc := &ContextCompressor{
|
||||
contextLength: contextLength,
|
||||
thresholdTokens: contextLength * defaultThresholdPercent / 100,
|
||||
protectFirstN: defaultProtectFirstN,
|
||||
protectLastN: defaultProtectLastN,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cc)
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// ShouldCompress returns true if the current token count exceeds threshold.
|
||||
func (cc *ContextCompressor) ShouldCompress(promptTokens int) bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
return promptTokens >= cc.thresholdTokens
|
||||
}
|
||||
|
||||
// UpdateFromResponse tracks token usage from the last LLM response.
|
||||
func (cc *ContextCompressor) UpdateFromResponse(usage *providers.UsageInfo) {
|
||||
if usage == nil {
|
||||
return
|
||||
}
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.lastPromptTokens = usage.PromptTokens
|
||||
}
|
||||
|
||||
// GetStatus returns compression statistics.
|
||||
func (cc *ContextCompressor) GetStatus() map[string]any {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
return map[string]any{
|
||||
"context_length": cc.contextLength,
|
||||
"threshold_tokens": cc.thresholdTokens,
|
||||
"compression_count": cc.compressionCount,
|
||||
"has_summary": cc.previousSummary != "",
|
||||
"last_prompt_tokens": cc.lastPromptTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// Compress runs the 6-phase algorithm and returns compressed messages
|
||||
// plus a structured summary string suitable for the session summary.
|
||||
//
|
||||
// The summary is generated as a template — the caller should pass it to
|
||||
// an LLM for actual summarization. This keeps the compressor LLM-agnostic.
|
||||
func (cc *ContextCompressor) Compress(messages []providers.Message) (compressed []providers.Message, summaryInput string) {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
|
||||
if len(messages) <= cc.protectFirstN+cc.protectLastN {
|
||||
return messages, ""
|
||||
}
|
||||
|
||||
// Phase 1: Prune old tool results (cheap, no LLM).
|
||||
pruned, prunedCount := cc.pruneOldToolResults(messages, cc.protectLastN)
|
||||
|
||||
// Phase 2+3: Determine boundaries.
|
||||
headEnd := cc.protectFirstN
|
||||
if headEnd > len(pruned) {
|
||||
headEnd = len(pruned)
|
||||
}
|
||||
tailStart := cc.findTailCut(pruned, headEnd)
|
||||
|
||||
head := pruned[:headEnd]
|
||||
middle := pruned[headEnd:tailStart]
|
||||
tail := pruned[tailStart:]
|
||||
|
||||
if len(middle) == 0 {
|
||||
return messages, ""
|
||||
}
|
||||
|
||||
// Phase 4: Serialize middle for summarization.
|
||||
summaryInput = cc.serializeForSummary(middle)
|
||||
|
||||
// Build structured summary prompt.
|
||||
var sb strings.Builder
|
||||
if cc.previousSummary != "" {
|
||||
sb.WriteString("UPDATE the previous summary with NEW TURNS below.\n")
|
||||
sb.WriteString("PRESERVE all existing information that is still relevant.\n\n")
|
||||
sb.WriteString("PREVIOUS SUMMARY:\n")
|
||||
sb.WriteString(cc.previousSummary)
|
||||
sb.WriteString("\n\nNEW TURNS:\n")
|
||||
} else {
|
||||
sb.WriteString("Create a structured handoff summary of this conversation:\n\n")
|
||||
}
|
||||
sb.WriteString(summaryInput)
|
||||
sb.WriteString("\n\nUse this structure:\n")
|
||||
sb.WriteString("## Goal\n## Progress\n### Done\n### In Progress\n")
|
||||
sb.WriteString("## Key Decisions\n## Relevant Files\n## Next Steps\n## Critical Context\n")
|
||||
|
||||
summaryPrompt := sb.String()
|
||||
|
||||
// Phase 5: Assemble — head + placeholder for summary + tail.
|
||||
// The actual summary will be injected by the caller after LLM generates it.
|
||||
compressed = make([]providers.Message, 0, len(head)+1+len(tail))
|
||||
compressed = append(compressed, head...)
|
||||
|
||||
// Add compression notice.
|
||||
notice := fmt.Sprintf("[Context compressed: %d messages summarized, %d tool results pruned. Compression #%d]",
|
||||
len(middle), prunedCount, cc.compressionCount+1)
|
||||
compressed = append(compressed, providers.Message{
|
||||
Role: "system",
|
||||
Content: notice,
|
||||
})
|
||||
|
||||
compressed = append(compressed, tail...)
|
||||
|
||||
// Phase 6: Sanitize tool pairs.
|
||||
compressed = cc.sanitizeToolPairs(compressed)
|
||||
|
||||
cc.compressionCount++
|
||||
|
||||
logger.DebugCF("compressor", "compressed context", map[string]any{
|
||||
"original": len(messages),
|
||||
"compressed": len(compressed),
|
||||
"middle_dropped": len(middle),
|
||||
"pruned_results": prunedCount,
|
||||
"compression_n": cc.compressionCount,
|
||||
})
|
||||
|
||||
return compressed, summaryPrompt
|
||||
}
|
||||
|
||||
// SetPreviousSummary stores the summary from the last compression
|
||||
// for iterative updates.
|
||||
func (cc *ContextCompressor) SetPreviousSummary(summary string) {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.previousSummary = summary
|
||||
}
|
||||
|
||||
// --- Internal phases ---
|
||||
|
||||
// pruneOldToolResults replaces long tool results outside the protected
|
||||
// tail with short placeholders. This is a cheap pre-pass (no LLM).
|
||||
func (cc *ContextCompressor) pruneOldToolResults(messages []providers.Message, protectTailCount int) ([]providers.Message, int) {
|
||||
pruned := make([]providers.Message, len(messages))
|
||||
copy(pruned, messages)
|
||||
|
||||
protectFrom := len(messages) - protectTailCount
|
||||
if protectFrom < 0 {
|
||||
protectFrom = 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i < protectFrom; i++ {
|
||||
if pruned[i].Role == "tool" && utf8.RuneCountInString(pruned[i].Content) > maxPrunedContentLen {
|
||||
pruned[i] = providers.Message{
|
||||
Role: "tool",
|
||||
Content: fmt.Sprintf("[Tool result truncated — originally %d chars]", utf8.RuneCountInString(messages[i].Content)),
|
||||
ToolCallID: messages[i].ToolCallID,
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
return pruned, count
|
||||
}
|
||||
|
||||
// findTailCut determines where the protected tail begins.
|
||||
// Uses token budget (20% of context) walking backwards.
|
||||
func (cc *ContextCompressor) findTailCut(messages []providers.Message, headEnd int) int {
|
||||
budget := cc.contextLength * 20 / 100 // 20% for tail
|
||||
tokens := 0
|
||||
|
||||
for i := len(messages) - 1; i >= headEnd; i-- {
|
||||
msgTokens := estimateTokens(messages[i])
|
||||
if tokens+msgTokens > budget {
|
||||
// Don't break tool_call/result pairs.
|
||||
cut := i + 1
|
||||
cut = alignToolBoundary(messages, cut)
|
||||
if cut <= headEnd {
|
||||
cut = headEnd + 1
|
||||
}
|
||||
return cut
|
||||
}
|
||||
tokens += msgTokens
|
||||
}
|
||||
|
||||
// Everything fits in tail budget — protect at least protectLastN.
|
||||
cut := len(messages) - cc.protectLastN
|
||||
if cut < headEnd {
|
||||
cut = headEnd
|
||||
}
|
||||
return cut
|
||||
}
|
||||
|
||||
// serializeForSummary converts messages to a text format suitable for
|
||||
// LLM summarization.
|
||||
func (cc *ContextCompressor) serializeForSummary(turns []providers.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range turns {
|
||||
content := msg.Content
|
||||
if utf8.RuneCountInString(content) > 3000 {
|
||||
runes := []rune(content)
|
||||
content = string(runes[:1500]) + "\n...[truncated]...\n" + string(runes[len(runes)-1500:])
|
||||
}
|
||||
|
||||
role := strings.ToUpper(msg.Role)
|
||||
sb.WriteString(fmt.Sprintf("[%s]: %s\n", role, content))
|
||||
|
||||
// Include tool call names for context.
|
||||
for _, tc := range msg.ToolCalls {
|
||||
sb.WriteString(fmt.Sprintf(" → tool: %s\n", tc.Name))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// sanitizeToolPairs fixes orphaned tool_call/result pairs after compression.
|
||||
// - Tool result without matching assistant call → remove
|
||||
// - Assistant call without result → add stub
|
||||
func (cc *ContextCompressor) sanitizeToolPairs(messages []providers.Message) []providers.Message {
|
||||
// Collect surviving call IDs from assistant messages.
|
||||
callIDs := make(map[string]bool)
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "assistant" {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
callIDs[tc.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect result IDs.
|
||||
resultIDs := make(map[string]bool)
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "tool" && msg.ToolCallID != "" {
|
||||
resultIDs[msg.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var sanitized []providers.Message
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "tool" && msg.ToolCallID != "" {
|
||||
// Orphan result: call was compressed away.
|
||||
if !callIDs[msg.ToolCallID] {
|
||||
continue // skip
|
||||
}
|
||||
}
|
||||
sanitized = append(sanitized, msg)
|
||||
}
|
||||
|
||||
// Add stubs for calls without results.
|
||||
for id := range callIDs {
|
||||
if !resultIDs[id] {
|
||||
sanitized = append(sanitized, providers.Message{
|
||||
Role: "tool",
|
||||
Content: "[Result from earlier conversation — see context summary]",
|
||||
ToolCallID: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// estimateTokens gives a rough token estimate for a message.
|
||||
func estimateTokens(msg providers.Message) int {
|
||||
chars := utf8.RuneCountInString(msg.Content)
|
||||
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
||||
for _, tc := range msg.ToolCalls {
|
||||
chars += utf8.RuneCountInString(tc.Name) + 50 // args overhead
|
||||
}
|
||||
return chars / charsPerToken
|
||||
}
|
||||
|
||||
// alignToolBoundary moves a cut point forward to avoid splitting
|
||||
// a tool_call from its result.
|
||||
func alignToolBoundary(messages []providers.Message, cut int) int {
|
||||
if cut >= len(messages) {
|
||||
return cut
|
||||
}
|
||||
// If cut lands on a tool result, include the preceding assistant message.
|
||||
if messages[cut].Role == "tool" {
|
||||
for i := cut - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "assistant" && len(messages[i].ToolCalls) > 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return cut
|
||||
}
|
||||
139
pkg/agent/context_compressor_cm.go
Normal file
139
pkg/agent/context_compressor_cm.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// structuredContextManager wraps ContextCompressor as a ContextManager
|
||||
// implementation. It uses the 6-phase compression algorithm instead of
|
||||
// the legacy drop-oldest approach.
|
||||
//
|
||||
// Activate by setting: agents.defaults.context_manager = "structured"
|
||||
type structuredContextManager struct {
|
||||
al *AgentLoop
|
||||
compressor *ContextCompressor
|
||||
}
|
||||
|
||||
// structuredCMConfig is the JSON config for the structured context manager.
|
||||
type structuredCMConfig struct {
|
||||
ThresholdPercent int `json:"threshold_percent"`
|
||||
ProtectFirstN int `json:"protect_first_n"`
|
||||
ProtectLastN int `json:"protect_last_n"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = RegisterContextManager("structured", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("structured context manager: no default agent")
|
||||
}
|
||||
|
||||
var opts []CompressorOption
|
||||
|
||||
if cfg != nil {
|
||||
var c structuredCMConfig
|
||||
if err := json.Unmarshal(cfg, &c); err == nil {
|
||||
if c.ThresholdPercent > 0 {
|
||||
opts = append(opts, WithThresholdPercent(c.ThresholdPercent))
|
||||
}
|
||||
if c.ProtectFirstN > 0 {
|
||||
opts = append(opts, WithProtectFirstN(c.ProtectFirstN))
|
||||
}
|
||||
if c.ProtectLastN > 0 {
|
||||
opts = append(opts, WithProtectLastN(c.ProtectLastN))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressor := NewContextCompressor(agent.ContextWindow, opts...)
|
||||
|
||||
logger.InfoCF("agent", "structured context manager initialized", map[string]any{
|
||||
"context_window": agent.ContextWindow,
|
||||
})
|
||||
|
||||
return &structuredContextManager{
|
||||
al: al,
|
||||
compressor: compressor,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (m *structuredContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||
// Same as legacy: read history from session.
|
||||
agent := m.al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return &AssembleResponse{}, nil
|
||||
}
|
||||
history := agent.Sessions.GetHistory(req.SessionKey)
|
||||
summary := agent.Sessions.GetSummary(req.SessionKey)
|
||||
return &AssembleResponse{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *structuredContextManager) Compact(_ context.Context, req *CompactRequest) error {
|
||||
agent := m.al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
history := agent.Sessions.GetHistory(req.SessionKey)
|
||||
if len(history) <= 4 {
|
||||
return nil
|
||||
}
|
||||
|
||||
compressed, summaryPrompt := m.compressor.Compress(history)
|
||||
|
||||
if summaryPrompt == "" {
|
||||
// Nothing to compress — too few messages.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the summary prompt as the session summary.
|
||||
// In a full integration the caller would send summaryPrompt to an LLM
|
||||
// and store the response. For now, store a structured note.
|
||||
existingSummary := agent.Sessions.GetSummary(req.SessionKey)
|
||||
droppedCount := len(history) - len(compressed)
|
||||
|
||||
summaryNote := fmt.Sprintf(
|
||||
"[Structured compression #%d: %d messages compressed using 6-phase algorithm]",
|
||||
m.compressor.compressionCount, droppedCount,
|
||||
)
|
||||
if existingSummary != "" {
|
||||
summaryNote = existingSummary + "\n\n" + summaryNote
|
||||
}
|
||||
|
||||
agent.Sessions.SetSummary(req.SessionKey, summaryNote)
|
||||
agent.Sessions.SetHistory(req.SessionKey, compressed)
|
||||
agent.Sessions.Save(req.SessionKey)
|
||||
|
||||
m.al.emitEvent(
|
||||
EventKindContextCompress,
|
||||
m.al.newTurnEventScope("", req.SessionKey).meta(0, "structuredCompression", "turn.context.compress"),
|
||||
ContextCompressPayload{
|
||||
Reason: req.Reason,
|
||||
DroppedMessages: droppedCount,
|
||||
RemainingMessages: len(compressed),
|
||||
},
|
||||
)
|
||||
|
||||
logger.InfoCF("agent", "structured compression complete", map[string]any{
|
||||
"session_key": req.SessionKey,
|
||||
"original_msgs": len(history),
|
||||
"compressed_msgs": len(compressed),
|
||||
"dropped": droppedCount,
|
||||
"reason": req.Reason,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *structuredContextManager) Ingest(_ context.Context, _ *IngestRequest) error {
|
||||
// No-op: messages are persisted by Sessions JSONL.
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue