first commit
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// This file (US-003) ties the compaction pieces together: given a message list
|
||||
// and settings, it finds the cut point, extracts file operations from the
|
||||
// summarized range, generates the structured summary, and returns a
|
||||
// CompactionResult ready to be persisted as a session compaction entry and used
|
||||
// to rebuild the agent context. Mirrors pi's prepareCompaction + compact.
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
)
|
||||
|
||||
// CompactionDetails records the files touched in the compacted history, stored
|
||||
// alongside a compaction entry so a later iterative compaction can seed its
|
||||
// file lists. Mirrors pi's CompactionDetails.
|
||||
type CompactionDetails struct {
|
||||
// ReadFiles are files read but not modified in the compacted range.
|
||||
ReadFiles []string `json:"readFiles"`
|
||||
// ModifiedFiles are files written or edited in the compacted range.
|
||||
ModifiedFiles []string `json:"modifiedFiles"`
|
||||
}
|
||||
|
||||
// CompactionResult is the outcome of a compaction: the summary text (with file
|
||||
// metadata appended), the index where retained history begins, the estimated
|
||||
// tokens before compaction, and the extracted file details. Mirrors pi's
|
||||
// CompactionResult, adapted to pigo's flat message list (index vs entry id).
|
||||
type CompactionResult struct {
|
||||
// Summary is the structured summary that replaces the compacted history.
|
||||
Summary string
|
||||
// FirstKeptIndex is the index of the first retained message.
|
||||
FirstKeptIndex int
|
||||
// TokensBefore is the estimated context tokens before compaction.
|
||||
TokensBefore int
|
||||
// Details holds the file operations extracted from the compacted range.
|
||||
Details CompactionDetails
|
||||
}
|
||||
|
||||
// Compact prepares and generates a compaction over msgs. It cuts at
|
||||
// FindCutPoint(msgs, settings.KeepRecentTokens), summarizes messages in
|
||||
// [prevCompactionIndex+1, firstKeptIndex) (seeding file ops from a prior
|
||||
// compaction's details when provided), and appends <read-files>/<modified-files>
|
||||
// metadata to the summary. previousSummary, when non-empty, switches to the
|
||||
// iterative update template.
|
||||
//
|
||||
// prevCompactionIndex is the index of the last already-applied compaction point
|
||||
// (-1 when none); summarization starts just after it so each compaction only
|
||||
// covers the newly accumulated history. prevDetails carries that compaction's
|
||||
// file lists so long-lived reads/edits survive across successive compactions.
|
||||
//
|
||||
// It returns (nil, nil) when there is nothing to compact (no valid cut point or
|
||||
// an empty summarization range).
|
||||
func Compact(
|
||||
ctx context.Context,
|
||||
stream provider.StreamFn,
|
||||
model provider.Model,
|
||||
msgs []agentcore.Message,
|
||||
settings CompactionSettings,
|
||||
prevCompactionIndex int,
|
||||
prevDetails *CompactionDetails,
|
||||
previousSummary string,
|
||||
cfg provider.StreamConfig,
|
||||
) (*CompactionResult, error) {
|
||||
cut := FindCutPoint(msgs, settings.KeepRecentTokens)
|
||||
|
||||
start := prevCompactionIndex + 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= cut.FirstKeptIndex {
|
||||
// Nothing new to summarize.
|
||||
return nil, nil
|
||||
}
|
||||
toSummarize := msgs[start:cut.FirstKeptIndex]
|
||||
|
||||
// Seed file ops from the previous compaction, then fold in this range.
|
||||
ops := NewFileOps()
|
||||
if prevDetails != nil {
|
||||
for _, f := range prevDetails.ReadFiles {
|
||||
ops.Read[f] = struct{}{}
|
||||
}
|
||||
for _, f := range prevDetails.ModifiedFiles {
|
||||
ops.Edited[f] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, m := range toSummarize {
|
||||
extractFileOpsFromMessage(m, ops)
|
||||
}
|
||||
readFiles, modifiedFiles := computeFileLists(ops)
|
||||
|
||||
summary, err := GenerateSummary(ctx, stream, model, toSummarize, settings.ReserveTokens, previousSummary, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary += formatFileOperations(readFiles, modifiedFiles)
|
||||
|
||||
return &CompactionResult{
|
||||
Summary: summary,
|
||||
FirstKeptIndex: cut.FirstKeptIndex,
|
||||
TokensBefore: EstimateContextTokens(msgs).Tokens,
|
||||
Details: CompactionDetails{ReadFiles: readFiles, ModifiedFiles: modifiedFiles},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Message builds the CompactionMessage to persist for this result: the summary
|
||||
// text plus the estimated tokens-before and the file details (as raw JSON).
|
||||
func (r *CompactionResult) Message(now int64) agentcore.CompactionMessage {
|
||||
var details json.RawMessage
|
||||
if b, err := json.Marshal(r.Details); err == nil {
|
||||
details = b
|
||||
}
|
||||
return agentcore.CompactionMessage{
|
||||
RoleField: agentcore.RoleCompaction,
|
||||
Summary: r.Summary,
|
||||
TokensBefore: r.TokensBefore,
|
||||
Details: details,
|
||||
Timestamp: now,
|
||||
}
|
||||
}
|
||||
|
||||
// RebuildContext returns the post-compaction message list: the compaction
|
||||
// checkpoint followed by the retained recent messages (msgs[FirstKeptIndex:]).
|
||||
// The summarized prefix is dropped and replaced by the single checkpoint,
|
||||
// keeping context continuous while staying within the window. Mirrors pi's
|
||||
// post-compact context reconstruction (summary entry + retained tail).
|
||||
func (r *CompactionResult) RebuildContext(msgs []agentcore.Message, now int64) agentcore.MessageList {
|
||||
out := make(agentcore.MessageList, 0, len(msgs)-r.FirstKeptIndex+1)
|
||||
out = append(out, r.Message(now))
|
||||
out = append(out, msgs[r.FirstKeptIndex:]...)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package compaction
|
||||
|
||||
import "github.com/smallnest/pigo/internal/agentcore"
|
||||
|
||||
// CutPointResult describes the cut selected for compaction, mirroring pi's
|
||||
// CutPointResult (adapted to pigo's flat message list).
|
||||
type CutPointResult struct {
|
||||
// FirstKeptIndex is the index of the first message retained after
|
||||
// compaction; everything before it is summarized.
|
||||
FirstKeptIndex int
|
||||
// TurnStartIndex is the index of the user message that starts the turn the
|
||||
// cut falls inside, or -1 when the cut lands on a clean turn boundary.
|
||||
TurnStartIndex int
|
||||
// IsSplitTurn reports whether the cut splits an in-progress assistant turn.
|
||||
IsSplitTurn bool
|
||||
}
|
||||
|
||||
// isValidCutPoint reports whether a message may serve as a cut point. A cut may
|
||||
// land on a user or assistant message but never on a toolResult, because a
|
||||
// toolResult must stay attached to the toolCall that produced it (pi semantics).
|
||||
func isValidCutPoint(msg agentcore.Message) bool {
|
||||
switch msg.Role() {
|
||||
case agentcore.RoleUser, agentcore.RoleAssistant:
|
||||
return true
|
||||
default: // toolResult and any custom kinds are not cuttable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// findValidCutPoints returns the indices of all messages that may serve as cut
|
||||
// points, in ascending order.
|
||||
func findValidCutPoints(msgs []agentcore.Message) []int {
|
||||
var pts []int
|
||||
for i, m := range msgs {
|
||||
if isValidCutPoint(m) {
|
||||
pts = append(pts, i)
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// findTurnStartIndex scans backwards from idx to find the user message that
|
||||
// starts the turn containing idx, returning -1 if none is found.
|
||||
func findTurnStartIndex(msgs []agentcore.Message, idx int) int {
|
||||
for i := idx; i >= 0; i-- {
|
||||
if msgs[i].Role() == agentcore.RoleUser {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// FindCutPoint finds the compaction cut that keeps approximately
|
||||
// keepRecentTokens worth of the most recent messages. It accumulates token
|
||||
// estimates from the newest message backwards; once the retained budget is
|
||||
// reached it snaps to the nearest valid cut point at or after that message,
|
||||
// never splitting a toolCall from its toolResult. This mirrors pi's findCutPoint.
|
||||
//
|
||||
// When no valid cut point exists, it keeps everything (FirstKeptIndex 0).
|
||||
func FindCutPoint(msgs []agentcore.Message, keepRecentTokens int) CutPointResult {
|
||||
cutPoints := findValidCutPoints(msgs)
|
||||
if len(cutPoints) == 0 {
|
||||
return CutPointResult{FirstKeptIndex: 0, TurnStartIndex: -1, IsSplitTurn: false}
|
||||
}
|
||||
|
||||
// Default to the earliest valid cut point (keep as much as possible) when
|
||||
// the retained budget is never reached.
|
||||
cutIndex := cutPoints[0]
|
||||
|
||||
accumulated := 0
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
accumulated += EstimateTokens(msgs[i])
|
||||
if accumulated >= keepRecentTokens {
|
||||
// Snap to the nearest valid cut point at or after i, so the kept
|
||||
// window starts on a cuttable boundary.
|
||||
for _, c := range cutPoints {
|
||||
if c >= i {
|
||||
cutIndex = c
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cutOnUser := msgs[cutIndex].Role() == agentcore.RoleUser
|
||||
turnStart := -1
|
||||
if !cutOnUser {
|
||||
turnStart = findTurnStartIndex(msgs, cutIndex)
|
||||
}
|
||||
|
||||
return CutPointResult{
|
||||
FirstKeptIndex: cutIndex,
|
||||
TurnStartIndex: turnStart,
|
||||
IsSplitTurn: !cutOnUser && turnStart != -1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// bigUser builds a user message whose estimate is ~tokens tokens (4 chars each).
|
||||
func bigUser(tokens int) agentcore.UserMessage {
|
||||
return userMsg(strings.Repeat("x", tokens*4))
|
||||
}
|
||||
|
||||
func assistantToolCall(id, name string) agentcore.AssistantMessage {
|
||||
return agentcore.AssistantMessage{
|
||||
RoleField: agentcore.RoleAssistant,
|
||||
Content: agentcore.ContentList{agentcore.NewToolCallContent(id, name, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
func toolResult(id string) agentcore.ToolResultMessage {
|
||||
return agentcore.ToolResultMessage{
|
||||
RoleField: agentcore.RoleToolResult,
|
||||
ToolCallID: id,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent("result")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCutPointNoValidCutPoint(t *testing.T) {
|
||||
// Only toolResult messages -> no valid cut point.
|
||||
msgs := []agentcore.Message{toolResult("a"), toolResult("b")}
|
||||
got := FindCutPoint(msgs, 100)
|
||||
if got.FirstKeptIndex != 0 || got.TurnStartIndex != -1 || got.IsSplitTurn {
|
||||
t.Fatalf("no-cutpoint: got %+v, want {0 -1 false}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCutPointOnTurnBoundary(t *testing.T) {
|
||||
// Three complete user/assistant turns, each user ~100 tokens.
|
||||
// keepRecentTokens small so the cut lands on a clean user boundary.
|
||||
msgs := []agentcore.Message{
|
||||
bigUser(100), // 0
|
||||
assistantMsg("a1", nil, ""), // 1
|
||||
bigUser(100), // 2
|
||||
assistantMsg("a2", nil, ""), // 3
|
||||
bigUser(100), // 4
|
||||
assistantMsg("a3", nil, ""), // 5
|
||||
}
|
||||
// keepRecentTokens=50: walking back, msg[5] tiny, msg[4]=100 >= 50 at i=4.
|
||||
// nearest cut point >= 4 is index 4 (a user message) -> clean boundary.
|
||||
got := FindCutPoint(msgs, 50)
|
||||
if got.FirstKeptIndex != 4 {
|
||||
t.Fatalf("FirstKeptIndex: got %d, want 4", got.FirstKeptIndex)
|
||||
}
|
||||
if got.IsSplitTurn {
|
||||
t.Fatalf("IsSplitTurn: got true, want false (landed on user boundary)")
|
||||
}
|
||||
if got.TurnStartIndex != -1 {
|
||||
t.Fatalf("TurnStartIndex: got %d, want -1", got.TurnStartIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCutPointSplitTurn(t *testing.T) {
|
||||
// A turn starting with a user message, a big assistant reply, a toolCall and
|
||||
// its toolResult. If the budget forces the cut onto the assistant message
|
||||
// mid-turn, it's a split turn.
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("older turn"), // 0
|
||||
assistantMsg("older reply", nil, ""), // 1
|
||||
userMsg("current turn start"), // 2 user
|
||||
assistantMsg(strings.Repeat("y", 400), nil, ""), // 3 assistant ~100 tokens
|
||||
assistantToolCall("t1", "read"), // 4 assistant (valid cut)
|
||||
toolResult("t1"), // 5 toolResult (not cuttable)
|
||||
}
|
||||
// keepRecentTokens=50: from end, msg[5]=~2, msg[4]~1, msg[3]=100 >= 50 at i=3.
|
||||
// nearest cut point >= 3 is index 3 (assistant) -> split turn, turn start=2.
|
||||
got := FindCutPoint(msgs, 50)
|
||||
if got.FirstKeptIndex != 3 {
|
||||
t.Fatalf("FirstKeptIndex: got %d, want 3", got.FirstKeptIndex)
|
||||
}
|
||||
if !got.IsSplitTurn {
|
||||
t.Fatalf("IsSplitTurn: got false, want true (cut on assistant mid-turn)")
|
||||
}
|
||||
if got.TurnStartIndex != 2 {
|
||||
t.Fatalf("TurnStartIndex: got %d, want 2", got.TurnStartIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCutPointNeverCutsOnToolResult(t *testing.T) {
|
||||
// Ensure a toolResult is never chosen even when it's the message where the
|
||||
// budget is reached.
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("u0"), // 0
|
||||
assistantToolCall("t1", "grep"), // 1
|
||||
toolResult("t1"), // 2 (budget could land here)
|
||||
assistantMsg("done", nil, ""), // 3
|
||||
}
|
||||
got := FindCutPoint(msgs, 1) // tiny budget: reached at msg[3]
|
||||
// cut must be a valid point (>=3 is index 3 assistant); never index 2.
|
||||
if got.FirstKeptIndex == 2 {
|
||||
t.Fatalf("cut landed on toolResult (index 2), which is illegal")
|
||||
}
|
||||
if msgs[got.FirstKeptIndex].Role() == agentcore.RoleToolResult {
|
||||
t.Fatalf("FirstKeptIndex points at a toolResult: %d", got.FirstKeptIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCutPointBudgetNeverReachedKeepsEarliest(t *testing.T) {
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("u0"), // 0
|
||||
assistantMsg("a0", nil, ""), // 1
|
||||
}
|
||||
// Huge budget -> never reached -> keep from earliest valid cut point (0).
|
||||
got := FindCutPoint(msgs, 1_000_000)
|
||||
if got.FirstKeptIndex != 0 {
|
||||
t.Fatalf("FirstKeptIndex: got %d, want 0 (earliest cut point)", got.FirstKeptIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// This file (US-003) covers summarization: the structured prompts, the
|
||||
// conversation serializer, file-operation extraction, and GenerateSummary,
|
||||
// which drives a provider stream to turn compacted history into a structured
|
||||
// checkpoint summary. It mirrors pi's harness/compaction/compaction.ts prompts
|
||||
// and utils.ts helpers, adapted to pigo's flat message list and Provider stream.
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
)
|
||||
|
||||
// SUMMARIZATION_SYSTEM_PROMPT instructs the model to only emit the structured
|
||||
// summary and never continue the conversation. Verbatim from pi.
|
||||
const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
|
||||
|
||||
// summarizationPrompt is the first-time summary template (pi's SUMMARIZATION_PROMPT).
|
||||
const summarizationPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// updateSummarizationPrompt incorporates new messages into an existing summary
|
||||
// (pi's UPDATE_SUMMARIZATION_PROMPT).
|
||||
const updateSummarizationPrompt = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
||||
|
||||
Update the existing structured summary with new information. RULES:
|
||||
- PRESERVE all existing information from the previous summary
|
||||
- ADD new progress, decisions, and context from the new messages
|
||||
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
||||
- UPDATE "Next Steps" based on what was accomplished
|
||||
- PRESERVE exact file paths, function names, and error messages
|
||||
- If something is no longer relevant, you may remove it
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals, add new ones if the task expanded]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Preserve existing, add new ones discovered]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Include previously done items AND newly completed items]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work - update based on progress]
|
||||
|
||||
### Blocked
|
||||
- [Current blockers - remove if resolved]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
||||
|
||||
## Next Steps
|
||||
1. [Update based on current state]
|
||||
|
||||
## Critical Context
|
||||
- [Preserve important context, add new if needed]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// FileOperations accumulates the file paths a compaction range touched, split
|
||||
// by access kind. Mirrors pi's FileOperations.
|
||||
type FileOperations struct {
|
||||
// Read holds files read but not necessarily modified.
|
||||
Read map[string]struct{}
|
||||
// Written holds files written by full-file write operations.
|
||||
Written map[string]struct{}
|
||||
// Edited holds files modified by edit operations.
|
||||
Edited map[string]struct{}
|
||||
}
|
||||
|
||||
// NewFileOps returns an empty file-operation accumulator.
|
||||
func NewFileOps() FileOperations {
|
||||
return FileOperations{
|
||||
Read: map[string]struct{}{},
|
||||
Written: map[string]struct{}{},
|
||||
Edited: map[string]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// extractFileOpsFromMessage adds file operations from an assistant message's
|
||||
// tool calls to the accumulator, keyed on the tool name (read/write/edit) and
|
||||
// the string "path" argument. Non-assistant messages are ignored, matching pi.
|
||||
func extractFileOpsFromMessage(msg agentcore.Message, ops FileOperations) {
|
||||
a, ok := msg.(agentcore.AssistantMessage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, call := range a.ToolCalls() {
|
||||
path := toolCallPath(call.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
switch call.Name {
|
||||
case "read":
|
||||
ops.Read[path] = struct{}{}
|
||||
case "write":
|
||||
ops.Written[path] = struct{}{}
|
||||
case "edit":
|
||||
ops.Edited[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolCallPath extracts a string "path" argument from a tool call's raw
|
||||
// arguments, returning "" when absent or not a string.
|
||||
func toolCallPath(args json.RawMessage) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
var decoded struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
return decoded.Path
|
||||
}
|
||||
|
||||
// computeFileLists derives sorted read-only and modified (edited∪written) file
|
||||
// lists, excluding modified files from the read-only list. Mirrors pi.
|
||||
func computeFileLists(ops FileOperations) (readFiles, modifiedFiles []string) {
|
||||
modified := map[string]struct{}{}
|
||||
for f := range ops.Edited {
|
||||
modified[f] = struct{}{}
|
||||
}
|
||||
for f := range ops.Written {
|
||||
modified[f] = struct{}{}
|
||||
}
|
||||
for f := range ops.Read {
|
||||
if _, isMod := modified[f]; !isMod {
|
||||
readFiles = append(readFiles, f)
|
||||
}
|
||||
}
|
||||
for f := range modified {
|
||||
modifiedFiles = append(modifiedFiles, f)
|
||||
}
|
||||
sort.Strings(readFiles)
|
||||
sort.Strings(modifiedFiles)
|
||||
return readFiles, modifiedFiles
|
||||
}
|
||||
|
||||
// formatFileOperations renders the file lists as <read-files>/<modified-files>
|
||||
// metadata blocks appended to a summary, or "" when both lists are empty.
|
||||
func formatFileOperations(readFiles, modifiedFiles []string) string {
|
||||
var sections []string
|
||||
if len(readFiles) > 0 {
|
||||
sections = append(sections, "<read-files>\n"+strings.Join(readFiles, "\n")+"\n</read-files>")
|
||||
}
|
||||
if len(modifiedFiles) > 0 {
|
||||
sections = append(sections, "<modified-files>\n"+strings.Join(modifiedFiles, "\n")+"\n</modified-files>")
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "\n\n" + strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// toolResultMaxChars caps a tool result's serialized text in the summarization
|
||||
// prompt, matching pi's TOOL_RESULT_MAX_CHARS.
|
||||
const toolResultMaxChars = 2000
|
||||
|
||||
// truncateForSummary caps text at maxChars, appending a truncation marker.
|
||||
func truncateForSummary(text string, maxChars int) string {
|
||||
if len(text) <= maxChars {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("%s\n\n[... %d more characters truncated]", text[:maxChars], len(text)-maxChars)
|
||||
}
|
||||
|
||||
// textOf concatenates the text blocks of a content list.
|
||||
func textOf(content agentcore.ContentList) string {
|
||||
var b strings.Builder
|
||||
for _, c := range content {
|
||||
if t, ok := c.(agentcore.TextContent); ok {
|
||||
b.WriteString(t.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// serializeConversation renders messages as a plain-text transcript for the
|
||||
// summarization prompt, mirroring pi's serializeConversation: user text,
|
||||
// assistant thinking/text/tool-call lines, and truncated tool results.
|
||||
func serializeConversation(msgs []agentcore.Message) string {
|
||||
var parts []string
|
||||
for _, msg := range msgs {
|
||||
switch m := msg.(type) {
|
||||
case agentcore.UserMessage:
|
||||
if s := textOf(m.Content); s != "" {
|
||||
parts = append(parts, "[User]: "+s)
|
||||
}
|
||||
case agentcore.AssistantMessage:
|
||||
var textParts, thinkingParts, toolCalls []string
|
||||
for _, block := range m.Content {
|
||||
switch c := block.(type) {
|
||||
case agentcore.TextContent:
|
||||
textParts = append(textParts, c.Text)
|
||||
case agentcore.ThinkingContent:
|
||||
thinkingParts = append(thinkingParts, c.Thinking)
|
||||
case agentcore.ToolCallContent:
|
||||
toolCalls = append(toolCalls, formatToolCall(c))
|
||||
}
|
||||
}
|
||||
if len(thinkingParts) > 0 {
|
||||
parts = append(parts, "[Assistant thinking]: "+strings.Join(thinkingParts, "\n"))
|
||||
}
|
||||
if len(textParts) > 0 {
|
||||
parts = append(parts, "[Assistant]: "+strings.Join(textParts, "\n"))
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
parts = append(parts, "[Assistant tool calls]: "+strings.Join(toolCalls, "; "))
|
||||
}
|
||||
case agentcore.ToolResultMessage:
|
||||
if s := textOf(m.Content); s != "" {
|
||||
parts = append(parts, "[Tool result]: "+truncateForSummary(s, toolResultMaxChars))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// formatToolCall renders a tool call as name(key=value, ...) with each value
|
||||
// JSON-encoded, mirroring pi's serialization of tool-call arguments.
|
||||
func formatToolCall(c agentcore.ToolCallContent) string {
|
||||
if len(c.Arguments) == 0 {
|
||||
return c.Name + "()"
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(c.Arguments, &m); err != nil {
|
||||
// Not an object: fall back to the raw argument text.
|
||||
return fmt.Sprintf("%s(%s)", c.Name, string(c.Arguments))
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys) // deterministic order (Go map iteration is random)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k+"="+string(m[k]))
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", c.Name, strings.Join(pairs, ", "))
|
||||
}
|
||||
|
||||
// GenerateSummary drives a provider stream to summarize msgs into a structured
|
||||
// checkpoint. When previousSummary is non-empty it uses the update template and
|
||||
// embeds the prior summary in <previous-summary> tags; otherwise it uses the
|
||||
// first-time template. maxTokens is bounded to min(0.8*reserveTokens,
|
||||
// model.MaxOutputTokens) as in pi. The returned text is the concatenation of
|
||||
// the assistant response's text blocks. A terminal error/aborted response is
|
||||
// surfaced as an error.
|
||||
func GenerateSummary(
|
||||
ctx context.Context,
|
||||
stream provider.StreamFn,
|
||||
model provider.Model,
|
||||
msgs []agentcore.Message,
|
||||
reserveTokens int,
|
||||
previousSummary string,
|
||||
cfg provider.StreamConfig,
|
||||
) (string, error) {
|
||||
base := summarizationPrompt
|
||||
if previousSummary != "" {
|
||||
base = updateSummarizationPrompt
|
||||
}
|
||||
|
||||
conversation := serializeConversation(msgs)
|
||||
var b strings.Builder
|
||||
b.WriteString("<conversation>\n")
|
||||
b.WriteString(conversation)
|
||||
b.WriteString("\n</conversation>\n\n")
|
||||
if previousSummary != "" {
|
||||
b.WriteString("<previous-summary>\n")
|
||||
b.WriteString(previousSummary)
|
||||
b.WriteString("\n</previous-summary>\n\n")
|
||||
}
|
||||
b.WriteString(base)
|
||||
|
||||
promptMsg := agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(b.String())},
|
||||
}
|
||||
|
||||
// Bound the summary output to min(0.8*reserveTokens, model max output).
|
||||
maxTokens := (reserveTokens * 8) / 10
|
||||
if model.MaxOutputTokens > 0 && model.MaxOutputTokens < maxTokens {
|
||||
maxTokens = model.MaxOutputTokens
|
||||
}
|
||||
extra := map[string]any{}
|
||||
for k, v := range cfg.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
if maxTokens > 0 {
|
||||
extra["max_tokens"] = maxTokens
|
||||
}
|
||||
cfg.Extra = extra
|
||||
|
||||
llm := provider.LlmContext{
|
||||
SystemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
|
||||
Messages: agentcore.MessageList{promptMsg},
|
||||
}
|
||||
s, err := stream(ctx, model.ID, llm, cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compaction: build summary stream: %w", err)
|
||||
}
|
||||
|
||||
// Drain events so the stream's result is populated, then read the final
|
||||
// message. Failures ride the stream as a terminal message per the provider
|
||||
// contract, so inspect StopReason rather than only the returned error.
|
||||
for range s.Events() {
|
||||
}
|
||||
final, resErr := s.Result(ctx)
|
||||
if resErr != nil {
|
||||
return "", fmt.Errorf("compaction: summary stream: %w", resErr)
|
||||
}
|
||||
switch final.StopReason {
|
||||
case agentcore.StopReasonAborted:
|
||||
return "", fmt.Errorf("compaction: summarization aborted: %s", final.ErrorMessage)
|
||||
case agentcore.StopReasonError:
|
||||
return "", fmt.Errorf("compaction: summarization failed: %s", final.ErrorMessage)
|
||||
}
|
||||
|
||||
summary := textOf(final.Content)
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return "", fmt.Errorf("compaction: summarization produced empty output")
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
)
|
||||
|
||||
func TestExtractFileOpsAndLists(t *testing.T) {
|
||||
readArgs, _ := json.Marshal(map[string]string{"path": "a.go"})
|
||||
writeArgs, _ := json.Marshal(map[string]string{"path": "b.go"})
|
||||
editArgs, _ := json.Marshal(map[string]string{"path": "a.go"}) // a.go also edited -> modified wins
|
||||
msgs := []agentcore.Message{
|
||||
assistantToolCall("1", "read"),
|
||||
assistantToolCall("2", "write"),
|
||||
assistantToolCall("3", "edit"),
|
||||
}
|
||||
// Attach args by rebuilding with arguments.
|
||||
msgs[0] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("1", "read", readArgs)}}
|
||||
msgs[1] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("2", "write", writeArgs)}}
|
||||
msgs[2] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("3", "edit", editArgs)}}
|
||||
|
||||
ops := NewFileOps()
|
||||
for _, m := range msgs {
|
||||
extractFileOpsFromMessage(m, ops)
|
||||
}
|
||||
read, modified := computeFileLists(ops)
|
||||
// a.go was read AND edited -> only in modified; b.go written -> modified.
|
||||
if len(read) != 0 {
|
||||
t.Fatalf("readFiles: got %v, want []", read)
|
||||
}
|
||||
if strings.Join(modified, ",") != "a.go,b.go" {
|
||||
t.Fatalf("modifiedFiles: got %v, want [a.go b.go]", modified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFileOperations(t *testing.T) {
|
||||
if got := formatFileOperations(nil, nil); got != "" {
|
||||
t.Fatalf("empty: got %q, want empty", got)
|
||||
}
|
||||
got := formatFileOperations([]string{"r.go"}, []string{"m.go"})
|
||||
if !strings.Contains(got, "<read-files>\nr.go\n</read-files>") {
|
||||
t.Fatalf("missing read-files block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<modified-files>\nm.go\n</modified-files>") {
|
||||
t.Fatalf("missing modified-files block: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeConversation(t *testing.T) {
|
||||
args, _ := json.Marshal(map[string]any{"path": "x.go", "n": 1})
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("hello"),
|
||||
agentcore.AssistantMessage{
|
||||
RoleField: agentcore.RoleAssistant,
|
||||
Content: agentcore.ContentList{
|
||||
agentcore.NewThinkingContent("thinking hard"),
|
||||
agentcore.NewTextContent("here goes"),
|
||||
agentcore.NewToolCallContent("t1", "read", args),
|
||||
},
|
||||
},
|
||||
toolResult("t1"),
|
||||
}
|
||||
got := serializeConversation(msgs)
|
||||
for _, want := range []string{
|
||||
"[User]: hello",
|
||||
"[Assistant thinking]: thinking hard",
|
||||
"[Assistant]: here goes",
|
||||
"[Assistant tool calls]: read(",
|
||||
"[Tool result]: result",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("serialize missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateForSummary(t *testing.T) {
|
||||
if got := truncateForSummary("short", 100); got != "short" {
|
||||
t.Fatalf("no truncation expected: %q", got)
|
||||
}
|
||||
long := strings.Repeat("z", 2500)
|
||||
got := truncateForSummary(long, toolResultMaxChars)
|
||||
if !strings.Contains(got, "more characters truncated") {
|
||||
t.Fatalf("expected truncation marker: %q", got[len(got)-60:])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStreamFn returns a StreamFn that yields a single done event with the
|
||||
// given assistant message, capturing the LlmContext it was called with.
|
||||
func fakeStreamFn(final agentcore.AssistantMessage, capture *provider.LlmContext) provider.StreamFn {
|
||||
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
|
||||
if capture != nil {
|
||||
*capture = llm
|
||||
}
|
||||
s := provider.NewAssistantMessageEventStream(4)
|
||||
go func() {
|
||||
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
|
||||
s.SetResult(final)
|
||||
s.Close()
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
func assistantText(text string) agentcore.AssistantMessage {
|
||||
return agentcore.AssistantMessage{
|
||||
RoleField: agentcore.RoleAssistant,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
|
||||
StopReason: agentcore.StopReasonEndTurn,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSummaryFirstTime(t *testing.T) {
|
||||
var captured provider.LlmContext
|
||||
stream := fakeStreamFn(assistantText("## Goal\ndo the thing"), &captured)
|
||||
model := provider.Model{ID: "m", MaxOutputTokens: 8000}
|
||||
msgs := []agentcore.Message{userMsg("please do X")}
|
||||
|
||||
got, err := GenerateSummary(context.Background(), stream, model, msgs, 16384, "", provider.StreamConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSummary: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "## Goal") {
|
||||
t.Fatalf("summary text: %q", got)
|
||||
}
|
||||
// System prompt must be the summarization system prompt.
|
||||
if captured.SystemPrompt != SUMMARIZATION_SYSTEM_PROMPT {
|
||||
t.Fatalf("system prompt mismatch")
|
||||
}
|
||||
// First-time prompt uses the non-update template and wraps the conversation.
|
||||
promptText := textOf(captured.Messages[0].(agentcore.UserMessage).Content)
|
||||
if !strings.Contains(promptText, "<conversation>") || strings.Contains(promptText, "<previous-summary>") {
|
||||
t.Fatalf("first-time prompt shape wrong:\n%s", promptText)
|
||||
}
|
||||
if !strings.Contains(promptText, "Create a structured context checkpoint") {
|
||||
t.Fatalf("expected first-time template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSummaryUpdateUsesPrevious(t *testing.T) {
|
||||
var captured provider.LlmContext
|
||||
stream := fakeStreamFn(assistantText("updated summary"), &captured)
|
||||
model := provider.Model{ID: "m"}
|
||||
msgs := []agentcore.Message{userMsg("more work")}
|
||||
|
||||
_, err := GenerateSummary(context.Background(), stream, model, msgs, 16384, "PRIOR SUMMARY", provider.StreamConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSummary: %v", err)
|
||||
}
|
||||
promptText := textOf(captured.Messages[0].(agentcore.UserMessage).Content)
|
||||
if !strings.Contains(promptText, "<previous-summary>\nPRIOR SUMMARY") {
|
||||
t.Fatalf("update prompt should embed previous summary:\n%s", promptText)
|
||||
}
|
||||
if !strings.Contains(promptText, "NEW conversation messages to incorporate") {
|
||||
t.Fatalf("expected update template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSummaryMaxTokensCap(t *testing.T) {
|
||||
var gotMax int
|
||||
stream := func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
|
||||
if v, ok := cfg.Extra["max_tokens"].(int); ok {
|
||||
gotMax = v
|
||||
}
|
||||
s := provider.NewAssistantMessageEventStream(2)
|
||||
go func() {
|
||||
m := assistantText("ok")
|
||||
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: m})
|
||||
s.SetResult(m)
|
||||
s.Close()
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
// 0.8 * 16384 = 13107, but model max output is 5000 -> cap at 5000.
|
||||
model := provider.Model{ID: "m", MaxOutputTokens: 5000}
|
||||
_, err := GenerateSummary(context.Background(), stream, model, []agentcore.Message{userMsg("x")}, 16384, "", provider.StreamConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSummary: %v", err)
|
||||
}
|
||||
if gotMax != 5000 {
|
||||
t.Fatalf("max_tokens: got %d, want 5000", gotMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSummaryErrorStopReason(t *testing.T) {
|
||||
errMsg := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonError, ErrorMessage: "boom"}
|
||||
stream := fakeStreamFn(errMsg, nil)
|
||||
_, err := GenerateSummary(context.Background(), stream, provider.Model{ID: "m"}, []agentcore.Message{userMsg("x")}, 16384, "", provider.StreamConfig{})
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected error containing 'boom', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRebuildsContext(t *testing.T) {
|
||||
readArgs, _ := json.Marshal(map[string]string{"path": "old.go"})
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("turn one"), // 0
|
||||
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("t1", "read", readArgs)}}, // 1
|
||||
toolResult("t1"), // 2
|
||||
bigUser(100), // 3
|
||||
assistantMsg("recent", nil, ""), // 4
|
||||
}
|
||||
stream := fakeStreamFn(assistantText("## Goal\nx"), nil)
|
||||
// Small keepRecentTokens so the cut lands on the recent bigUser(100) turn,
|
||||
// leaving the earlier read/toolResult prefix to be summarized.
|
||||
settings := CompactionSettings{Enabled: true, ReserveTokens: 16384, KeepRecentTokens: 50}
|
||||
res, err := Compact(context.Background(), stream, provider.Model{ID: "m"}, msgs, settings, -1, nil, "", provider.StreamConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("Compact returned nil result")
|
||||
}
|
||||
// old.go was read in the summarized prefix.
|
||||
if strings.Join(res.Details.ReadFiles, ",") != "old.go" {
|
||||
t.Fatalf("readFiles: got %v, want [old.go]", res.Details.ReadFiles)
|
||||
}
|
||||
if !strings.Contains(res.Summary, "<read-files>") {
|
||||
t.Fatalf("summary should carry file metadata: %q", res.Summary)
|
||||
}
|
||||
rebuilt := res.RebuildContext(msgs, 123)
|
||||
if rebuilt[0].Role() != agentcore.RoleCompaction {
|
||||
t.Fatalf("first rebuilt message must be compaction, got %s", rebuilt[0].Role())
|
||||
}
|
||||
// Retained tail begins at FirstKeptIndex.
|
||||
if len(rebuilt) != 1+(len(msgs)-res.FirstKeptIndex) {
|
||||
t.Fatalf("rebuilt length: got %d", len(rebuilt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactNothingToSummarize(t *testing.T) {
|
||||
// prevCompactionIndex already at/after the cut -> nil result.
|
||||
msgs := []agentcore.Message{userMsg("a"), assistantMsg("b", nil, "")}
|
||||
stream := fakeStreamFn(assistantText("unused"), nil)
|
||||
res, err := Compact(context.Background(), stream, provider.Model{ID: "m"}, msgs, DefaultCompactionSettings, 5, nil, "", provider.StreamConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
if res != nil {
|
||||
t.Fatalf("expected nil result when nothing to summarize, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionMessageRoundTrip(t *testing.T) {
|
||||
details, _ := json.Marshal(CompactionDetails{ReadFiles: []string{"a"}, ModifiedFiles: []string{"b"}})
|
||||
cm := agentcore.CompactionMessage{
|
||||
RoleField: agentcore.RoleCompaction,
|
||||
Summary: "the summary",
|
||||
TokensBefore: 42,
|
||||
Details: details,
|
||||
Timestamp: 7,
|
||||
}
|
||||
list := agentcore.MessageList{cm}
|
||||
raw, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back agentcore.MessageList
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(back) != 1 || back[0].Role() != agentcore.RoleCompaction {
|
||||
t.Fatalf("round-trip role: %+v", back)
|
||||
}
|
||||
got := back[0].(agentcore.CompactionMessage)
|
||||
if got.Summary != "the summary" || got.TokensBefore != 42 {
|
||||
t.Fatalf("round-trip fields: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package compaction implements context-window token accounting and the
|
||||
// decision of when a long session must be compacted, mirroring pi's
|
||||
// harness/compaction/compaction.ts.
|
||||
//
|
||||
// This file (US-001) covers the token side: estimating a message's token
|
||||
// footprint from a character heuristic, deriving the current context-token
|
||||
// usage (preferring provider-reported Usage over estimation), and the
|
||||
// ShouldCompact threshold check. Cut-point finding (US-002) and summarization
|
||||
// (US-003) live in sibling files.
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// CompactionSettings holds the thresholds and retention knobs for compaction,
|
||||
// mirroring pi's CompactionSettings.
|
||||
type CompactionSettings struct {
|
||||
// Enabled gates automatic compaction decisions.
|
||||
Enabled bool
|
||||
// ReserveTokens is reserved for the summary prompt and its output; the
|
||||
// effective usable window is contextWindow - ReserveTokens.
|
||||
ReserveTokens int
|
||||
// KeepRecentTokens is the approximate recent-context token budget to retain
|
||||
// after compaction (consumed by FindCutPoint in US-002).
|
||||
KeepRecentTokens int
|
||||
}
|
||||
|
||||
// DefaultCompactionSettings matches pi's DEFAULT_COMPACTION_SETTINGS.
|
||||
var DefaultCompactionSettings = CompactionSettings{
|
||||
Enabled: true,
|
||||
ReserveTokens: 16384,
|
||||
KeepRecentTokens: 20000,
|
||||
}
|
||||
|
||||
// estimatedImageChars is the fixed character budget attributed to an image
|
||||
// block, matching pi's ESTIMATED_IMAGE_CHARS.
|
||||
const estimatedImageChars = 4800
|
||||
|
||||
// charsPerToken is the conservative characters-per-token divisor pi uses.
|
||||
const charsPerToken = 4
|
||||
|
||||
// ceilDiv returns ceil(a / b) for non-negative a and positive b.
|
||||
func ceilDiv(a, b int) int {
|
||||
if a <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a + b - 1) / b
|
||||
}
|
||||
|
||||
// contentListChars sums the character footprint of a content list, counting
|
||||
// text/thinking/toolCall blocks by their text length and each image block as a
|
||||
// fixed estimatedImageChars, mirroring pi's estimateTextAndImageContentChars
|
||||
// plus its assistant-block handling.
|
||||
func contentListChars(content agentcore.ContentList) int {
|
||||
chars := 0
|
||||
for _, block := range content {
|
||||
switch c := block.(type) {
|
||||
case agentcore.TextContent:
|
||||
chars += len(c.Text)
|
||||
case agentcore.ThinkingContent:
|
||||
chars += len(c.Thinking)
|
||||
case agentcore.ToolCallContent:
|
||||
// name + serialized arguments, matching pi's toolCall accounting.
|
||||
chars += len(c.Name)
|
||||
if len(c.Arguments) > 0 {
|
||||
chars += len(c.Arguments)
|
||||
} else {
|
||||
// nil/empty RawMessage serializes to "null" downstream.
|
||||
b, _ := json.Marshal(json.RawMessage(c.Arguments))
|
||||
chars += len(b)
|
||||
}
|
||||
case agentcore.ImageContent:
|
||||
chars += estimatedImageChars
|
||||
}
|
||||
}
|
||||
return chars
|
||||
}
|
||||
|
||||
// EstimateTokens returns a conservative token estimate for one message using
|
||||
// the same character heuristic as pi's estimateTokens (ceil(chars / 4)).
|
||||
func EstimateTokens(msg agentcore.Message) int {
|
||||
switch m := msg.(type) {
|
||||
case agentcore.UserMessage:
|
||||
return ceilDiv(contentListChars(m.Content), charsPerToken)
|
||||
case agentcore.AssistantMessage:
|
||||
return ceilDiv(contentListChars(m.Content), charsPerToken)
|
||||
case agentcore.ToolResultMessage:
|
||||
return ceilDiv(contentListChars(m.Content), charsPerToken)
|
||||
case agentcore.CompactionMessage:
|
||||
// A compaction checkpoint replays as its summary text; estimate from it.
|
||||
return ceilDiv(len(m.Summary), charsPerToken)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// calculateContextTokens derives total context tokens from a provider usage
|
||||
// block. pigo's Usage only reports input/output, so we sum them (pi additionally
|
||||
// folds cache read/write, which pigo does not track).
|
||||
func calculateContextTokens(u agentcore.Usage) int {
|
||||
return u.InputTokens + u.OutputTokens
|
||||
}
|
||||
|
||||
// assistantUsage returns a usable Usage from an assistant message, skipping
|
||||
// aborted/error responses and zero-token usage, mirroring pi's getAssistantUsage.
|
||||
func assistantUsage(msg agentcore.Message) (agentcore.Usage, bool) {
|
||||
a, ok := msg.(agentcore.AssistantMessage)
|
||||
if !ok || a.Usage == nil {
|
||||
return agentcore.Usage{}, false
|
||||
}
|
||||
if a.StopReason == agentcore.StopReasonAborted || a.StopReason == agentcore.StopReasonError {
|
||||
return agentcore.Usage{}, false
|
||||
}
|
||||
if calculateContextTokens(*a.Usage) <= 0 {
|
||||
return agentcore.Usage{}, false
|
||||
}
|
||||
return *a.Usage, true
|
||||
}
|
||||
|
||||
// ContextUsageEstimate reports the derived context-token usage for a message
|
||||
// list, mirroring pi's ContextUsageEstimate.
|
||||
type ContextUsageEstimate struct {
|
||||
// Tokens is the estimated total context tokens.
|
||||
Tokens int
|
||||
// UsageTokens is the tokens reported by the most recent assistant usage block.
|
||||
UsageTokens int
|
||||
// TrailingTokens is the estimated tokens after that usage block.
|
||||
TrailingTokens int
|
||||
// LastUsageIndex is the index of the message that provided usage, or -1 when
|
||||
// none exists.
|
||||
LastUsageIndex int
|
||||
}
|
||||
|
||||
// EstimateContextTokens computes context-token usage for messages, preferring
|
||||
// the most recent valid assistant Usage block and estimating only the messages
|
||||
// that follow it. When no usage is available it estimates every message. This
|
||||
// mirrors pi's estimateContextTokens.
|
||||
func EstimateContextTokens(msgs []agentcore.Message) ContextUsageEstimate {
|
||||
lastIdx := -1
|
||||
var lastUsage agentcore.Usage
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if u, ok := assistantUsage(msgs[i]); ok {
|
||||
lastIdx = i
|
||||
lastUsage = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastIdx < 0 {
|
||||
estimated := 0
|
||||
for _, m := range msgs {
|
||||
estimated += EstimateTokens(m)
|
||||
}
|
||||
return ContextUsageEstimate{
|
||||
Tokens: estimated,
|
||||
UsageTokens: 0,
|
||||
TrailingTokens: estimated,
|
||||
LastUsageIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
usageTokens := calculateContextTokens(lastUsage)
|
||||
trailing := 0
|
||||
for i := lastIdx + 1; i < len(msgs); i++ {
|
||||
trailing += EstimateTokens(msgs[i])
|
||||
}
|
||||
return ContextUsageEstimate{
|
||||
Tokens: usageTokens + trailing,
|
||||
UsageTokens: usageTokens,
|
||||
TrailingTokens: trailing,
|
||||
LastUsageIndex: lastIdx,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether context usage has exceeded the usable window,
|
||||
// matching pi: contextTokens > contextWindow - reserveTokens. Disabled settings
|
||||
// or a non-positive contextWindow (unknown) never trigger compaction.
|
||||
func ShouldCompact(contextTokens, contextWindow int, settings CompactionSettings) bool {
|
||||
if !settings.Enabled {
|
||||
return false
|
||||
}
|
||||
if contextWindow <= 0 {
|
||||
return false
|
||||
}
|
||||
return contextTokens > contextWindow-settings.ReserveTokens
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func userMsg(text string) agentcore.UserMessage {
|
||||
return agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
|
||||
}
|
||||
}
|
||||
|
||||
func assistantMsg(text string, usage *agentcore.Usage, stop string) agentcore.AssistantMessage {
|
||||
return agentcore.AssistantMessage{
|
||||
RoleField: agentcore.RoleAssistant,
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
|
||||
Usage: usage,
|
||||
StopReason: stop,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokensText(t *testing.T) {
|
||||
// 8 chars / 4 = 2 tokens.
|
||||
if got := EstimateTokens(userMsg("abcdefgh")); got != 2 {
|
||||
t.Fatalf("text estimate: got %d, want 2", got)
|
||||
}
|
||||
// 9 chars -> ceil(9/4) = 3.
|
||||
if got := EstimateTokens(userMsg("abcdefghi")); got != 3 {
|
||||
t.Fatalf("ceil estimate: got %d, want 3", got)
|
||||
}
|
||||
// empty -> 0.
|
||||
if got := EstimateTokens(userMsg("")); got != 0 {
|
||||
t.Fatalf("empty estimate: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokensImage(t *testing.T) {
|
||||
m := agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: agentcore.ContentList{agentcore.NewImageContent("data", "image/png")},
|
||||
}
|
||||
// estimatedImageChars (4800) / 4 = 1200.
|
||||
if got := EstimateTokens(m); got != 1200 {
|
||||
t.Fatalf("image estimate: got %d, want 1200", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokensAssistantToolCall(t *testing.T) {
|
||||
args := json.RawMessage(`{"path":"a.go"}`) // 15 chars
|
||||
m := agentcore.AssistantMessage{
|
||||
RoleField: agentcore.RoleAssistant,
|
||||
Content: agentcore.ContentList{
|
||||
agentcore.NewToolCallContent("id1", "read", args), // name "read" = 4 chars
|
||||
},
|
||||
}
|
||||
// (4 + 15) / 4 = ceil(19/4) = 5.
|
||||
if got := EstimateTokens(m); got != 5 {
|
||||
t.Fatalf("toolcall estimate: got %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokensUnknownRoleZero(t *testing.T) {
|
||||
if got := EstimateTokens(agentcore.ToolResultMessage{}); got != 0 {
|
||||
t.Fatalf("empty tool result: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateContextTokensNoUsageFallsBackToEstimate(t *testing.T) {
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("abcdefgh"), // 2
|
||||
assistantMsg("abcd", nil, "end_turn"), // 1
|
||||
}
|
||||
est := EstimateContextTokens(msgs)
|
||||
if est.LastUsageIndex != -1 {
|
||||
t.Fatalf("LastUsageIndex: got %d, want -1", est.LastUsageIndex)
|
||||
}
|
||||
if est.Tokens != 3 {
|
||||
t.Fatalf("Tokens: got %d, want 3", est.Tokens)
|
||||
}
|
||||
if est.TrailingTokens != 3 || est.UsageTokens != 0 {
|
||||
t.Fatalf("trailing/usage: got %d/%d, want 3/0", est.TrailingTokens, est.UsageTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateContextTokensPrefersUsage(t *testing.T) {
|
||||
usage := &agentcore.Usage{InputTokens: 1000, OutputTokens: 200}
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("first"),
|
||||
assistantMsg("reply", usage, "end_turn"),
|
||||
userMsg("abcdefgh"), // trailing: 2 tokens
|
||||
}
|
||||
est := EstimateContextTokens(msgs)
|
||||
if est.LastUsageIndex != 1 {
|
||||
t.Fatalf("LastUsageIndex: got %d, want 1", est.LastUsageIndex)
|
||||
}
|
||||
if est.UsageTokens != 1200 {
|
||||
t.Fatalf("UsageTokens: got %d, want 1200", est.UsageTokens)
|
||||
}
|
||||
if est.TrailingTokens != 2 {
|
||||
t.Fatalf("TrailingTokens: got %d, want 2", est.TrailingTokens)
|
||||
}
|
||||
if est.Tokens != 1202 {
|
||||
t.Fatalf("Tokens: got %d, want 1202", est.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateContextTokensSkipsAbortedAndErrorUsage(t *testing.T) {
|
||||
good := &agentcore.Usage{InputTokens: 500, OutputTokens: 0}
|
||||
bad := &agentcore.Usage{InputTokens: 9999, OutputTokens: 0}
|
||||
msgs := []agentcore.Message{
|
||||
assistantMsg("ok", good, "end_turn"),
|
||||
assistantMsg("aborted", bad, agentcore.StopReasonAborted),
|
||||
assistantMsg("errored", bad, agentcore.StopReasonError),
|
||||
}
|
||||
est := EstimateContextTokens(msgs)
|
||||
if est.LastUsageIndex != 0 {
|
||||
t.Fatalf("LastUsageIndex: got %d, want 0 (should skip aborted/error)", est.LastUsageIndex)
|
||||
}
|
||||
if est.UsageTokens != 500 {
|
||||
t.Fatalf("UsageTokens: got %d, want 500", est.UsageTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateContextTokensSkipsZeroUsage(t *testing.T) {
|
||||
zero := &agentcore.Usage{InputTokens: 0, OutputTokens: 0}
|
||||
msgs := []agentcore.Message{
|
||||
userMsg("abcdefgh"), // 2
|
||||
assistantMsg("reply", zero, "end_turn"),
|
||||
}
|
||||
est := EstimateContextTokens(msgs)
|
||||
// zero usage is ignored, so falls back to full estimation.
|
||||
if est.LastUsageIndex != -1 {
|
||||
t.Fatalf("LastUsageIndex: got %d, want -1", est.LastUsageIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCompactThresholdBoundaries(t *testing.T) {
|
||||
s := CompactionSettings{Enabled: true, ReserveTokens: 16384}
|
||||
window := 200000
|
||||
usable := window - s.ReserveTokens // 183616
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contextTokens int
|
||||
want bool
|
||||
}{
|
||||
{"far below", 1000, false},
|
||||
{"equal to usable", usable, false}, // strictly greater required
|
||||
{"one over usable", usable + 1, true},
|
||||
{"far over", window * 2, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ShouldCompact(tt.contextTokens, window, s); got != tt.want {
|
||||
t.Fatalf("ShouldCompact(%d): got %v, want %v", tt.contextTokens, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCompactDisabled(t *testing.T) {
|
||||
s := CompactionSettings{Enabled: false, ReserveTokens: 16384}
|
||||
if ShouldCompact(1_000_000, 200000, s) {
|
||||
t.Fatal("disabled settings must never compact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCompactUnknownWindow(t *testing.T) {
|
||||
s := CompactionSettings{Enabled: true, ReserveTokens: 16384}
|
||||
if ShouldCompact(1_000_000, 0, s) {
|
||||
t.Fatal("unknown (0) context window must never compact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCompactionSettings(t *testing.T) {
|
||||
if DefaultCompactionSettings.ReserveTokens != 16384 {
|
||||
t.Fatalf("ReserveTokens: got %d, want 16384", DefaultCompactionSettings.ReserveTokens)
|
||||
}
|
||||
if DefaultCompactionSettings.KeepRecentTokens != 20000 {
|
||||
t.Fatalf("KeepRecentTokens: got %d, want 20000", DefaultCompactionSettings.KeepRecentTokens)
|
||||
}
|
||||
if !DefaultCompactionSettings.Enabled {
|
||||
t.Fatal("default settings should be enabled")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user