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 }