byteclaw/internal/agent/summarize.go

105 lines
3 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"strings"
"github.com/crypto/byteclaw/internal/llm"
"github.com/crypto/byteclaw/internal/store"
)
const summarizePrompt = `You compress chat histories. Write a concise summary of the conversation below so an assistant can continue it.
Keep facts, user preferences, decisions, results of tool calls and open tasks. Use the language of the conversation. No preamble.`
// estimateTokens is a cheap approximation (≈4 characters per token).
func estimateTokens(msgs []store.StoredMessage) int {
n := 0
for _, m := range msgs {
n += len(m.Content) / 4
for _, tc := range m.ToolCalls {
n += len(tc.Arguments) / 4
}
}
return n
}
// SummarizeNow summarises the session regardless of thresholds (manual trigger).
// Returns nil summary if there is nothing old enough to summarise.
func (a *Agent) SummarizeNow(ctx context.Context, sessionID string) (*store.Summary, error) {
l := a.sessionLock(sessionID)
l.Lock()
defer l.Unlock()
prov, err := a.provider(ctx, sessionID)
if err != nil {
return nil, err
}
return a.summarizeLocked(ctx, prov, sessionID, true)
}
func (a *Agent) summarizeLocked(ctx context.Context, prov llm.Provider, sessionID string, force bool) (*store.Summary, error) {
var after int64
prev := ""
if sm, err := a.Store.LatestSummary(ctx, sessionID); err == nil {
after, prev = sm.UptoMessageID, sm.Content
} else if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
msgs, err := a.Store.MessagesAfter(ctx, sessionID, after, 0)
if err != nil {
return nil, err
}
cfg := a.Summarize
if !force && len(msgs) <= cfg.MaxMessages && (cfg.MaxTokens <= 0 || estimateTokens(msgs) <= cfg.MaxTokens) {
return nil, nil
}
keep := cfg.KeepRecent
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
// The verbatim tail must start at a user message so tool call/result pairs stay together.
for cut > 0 && cut < len(msgs) && msgs[cut].Role != llm.RoleUser {
cut--
}
if cut <= 0 {
return nil, nil
}
var b strings.Builder
if prev != "" {
b.WriteString("Previous summary:\n" + prev + "\n\n")
}
b.WriteString("Conversation:\n")
for _, m := range msgs[:cut] {
switch {
case m.Role == llm.RoleTool:
fmt.Fprintf(&b, "tool %s result: %s\n", m.Name, clip(m.Content, 500))
case len(m.ToolCalls) > 0:
for _, tc := range m.ToolCalls {
fmt.Fprintf(&b, "assistant called %s(%s)\n", tc.Name, clip(tc.Arguments, 200))
}
if m.Content != "" {
fmt.Fprintf(&b, "assistant: %s\n", m.Content)
}
default:
fmt.Fprintf(&b, "%s: %s\n", m.Role, m.Content)
}
}
resp, err := prov.Chat(ctx, llm.Request{Messages: []llm.Message{
{Role: llm.RoleSystem, Content: summarizePrompt},
{Role: llm.RoleUser, Content: b.String()},
}}, nil)
if err != nil {
return nil, fmt.Errorf("summarize: %w", err)
}
text := strings.TrimSpace(resp.Content)
if text == "" {
return nil, errors.New("summarize: empty summary")
}
sm, err := a.Store.AddSummary(context.WithoutCancel(ctx), sessionID, msgs[cut-1].ID, text)
if err != nil {
return nil, err
}
return &sm, nil
}