Files
BlackBean/pigo/internal/compaction/summary.go
T
2026-08-14 23:41:57 +08:00

368 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (editedwritten) 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
}