105 lines
3 KiB
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
|
|
}
|