This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/compaction/compaction.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

315 lines
11 KiB
Go

package compaction
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
"switchboard-core/treepath"
)
// ── Request / Result ────────────────────────
// CompactRequest specifies what to compact.
type CompactRequest struct {
ChannelID string
UserID string // channel owner
TeamID *string // for role resolution
Trigger string // "manual" | "auto"
}
// CompactResult describes what compaction produced.
type CompactResult struct {
SummaryID string
SummarizedCount int
Model string
UsedFallback bool
Content string
InputTokens int
OutputTokens int
}
// ── Errors ──────────────────────────────────
var (
// ErrContextBudget is returned when the conversation to summarize
// exceeds the utility model's context window.
ErrContextBudget = fmt.Errorf("conversation exceeds utility model context window")
)
// ── Service ─────────────────────────────────
// Service provides conversation compaction (summarization).
// Used by both the HTTP handler (manual) and the background scanner (auto).
type Service struct {
stores store.Stores
resolver *roles.Resolver
}
// NewService creates a compaction service.
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
return &Service{stores: stores, resolver: resolver}
}
// Resolver exposes the underlying role resolver (for external checks like
// IsConfigured / IsPersonalOverride).
func (s *Service) Resolver() *roles.Resolver {
return s.resolver
}
// ── Compact ─────────────────────────────────
// Compact summarizes the conversation in a channel, inserting a summary
// tree node. Returns the result or an error.
//
// The method:
// 1. Loads the active path via the message tree
// 2. Finds the most recent summary boundary
// 3. Collects post-boundary messages
// 4. Checks the content fits the utility model's context window
// 5. Calls the utility model role to generate a summary
// 6. Inserts the summary as a tree node with metadata
// 7. Updates the user's cursor
// 8. Logs usage
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error) {
// ── Load active path ──
path, err := treepath.GetActivePath(req.ChannelID, req.UserID)
if err != nil {
return nil, fmt.Errorf("load conversation: %w", err)
}
if len(path) < 4 {
return nil, fmt.Errorf("conversation too short to summarize")
}
// Find existing summary boundary (if any) and only summarize after it
startIdx := 0
for i := range path {
if treepath.IsSummaryMessage(&path[i]) {
startIdx = i + 1
}
}
// Build messages to summarize (skip system messages, skip previous summaries)
var toSummarize []string
messagesInScope := 0
var lastMessageID string
for _, m := range path[startIdx:] {
if m.Role == "system" || treepath.IsSummaryMessage(&m) {
continue
}
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
messagesInScope++
lastMessageID = m.ID
}
if messagesInScope < 4 {
return nil, fmt.Errorf("not enough new messages since last summary")
}
// ── Build the summarization prompt ──
conversationText := strings.Join(toSummarize, "\n\n")
systemPrompt := `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
- Key decisions and conclusions reached
- Important facts, names, numbers, and technical details mentioned
- Action items or commitments made
- The overall context and topic flow
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`
userContent := "Summarize this conversation:\n\n" + conversationText
// ── Context budget guard rail ──
// Estimate whether the prompt fits the utility model's context window.
// Prevents sending truncated input to small models (e.g. 32K utility
// summarizing a 128K conversation).
promptTokens := EstimateTokens(systemPrompt) + 4 + EstimateTokens(userContent) + 4
utilityBudget := s.getUtilityContextBudget(ctx, req.UserID, req.TeamID)
// Reserve 20% for output (the summary itself)
inputCeiling := int(float64(utilityBudget) * 0.80)
if promptTokens > inputCeiling {
log.Printf("⚠ Compaction skipped for channel %s: prompt ~%dK tokens exceeds utility model budget %dK (ceiling %dK)",
req.ChannelID, promptTokens/1000, utilityBudget/1000, inputCeiling/1000)
return nil, fmt.Errorf("%w: ~%d tokens needed, utility model allows ~%d",
ErrContextBudget, promptTokens, inputCeiling)
}
summaryPrompt := []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userContent},
}
// ── Call utility role ──
log.Printf("📝 Compacting channel %s for user %s (%d messages, ~%dK tokens, trigger=%s)",
req.ChannelID, req.UserID, messagesInScope, promptTokens/1000, req.Trigger)
result, err := s.resolver.Complete(ctx, roles.RoleUtility, req.UserID, req.TeamID, summaryPrompt)
if err != nil {
log.Printf("⚠ Compaction failed for channel %s: %v", req.ChannelID, err)
return nil, fmt.Errorf("summarization failed: %w", err)
}
// ── Log usage ──
s.logUsage(ctx, req.ChannelID, req.UserID, req.Trigger, result)
// ── Insert summary message as a tree node ──
metadata := models.JSONMap{
"type": "summary",
"summarized_until_id": lastMessageID,
"summarized_count": messagesInScope,
"utility_model": result.Model,
"used_fallback": result.UsedFallback,
"trigger": req.Trigger,
}
metaJSON, _ := json.Marshal(metadata)
siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID)
summaryMsg := &models.Message{
ChannelID: req.ChannelID,
ParentID: &lastMessageID,
Role: "assistant",
Content: result.Content,
Model: result.Model,
Metadata: models.JSONMap{},
SiblingIndex: siblingIdx,
ParticipantType: "system",
}
_ = json.Unmarshal(metaJSON, &summaryMsg.Metadata)
if err := s.stores.Messages.Create(ctx, summaryMsg); err != nil {
log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err)
return nil, fmt.Errorf("failed to save summary: %w", err)
}
summaryMsgID := summaryMsg.ID
// Update cursor to point to the summary node
if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil {
log.Printf("⚠ Failed to update cursor after compaction: %v", err)
}
log.Printf("✅ Compaction done for channel %s: %s (%d messages → %d chars, trigger=%s)",
req.ChannelID, summaryMsgID, messagesInScope, len(result.Content), req.Trigger)
return &CompactResult{
SummaryID: summaryMsgID,
SummarizedCount: messagesInScope,
Model: result.Model,
UsedFallback: result.UsedFallback,
Content: result.Content,
InputTokens: result.InputTokens,
OutputTokens: result.OutputTokens,
}, nil
}
// ── Context Budget ──────────────────────────
// getUtilityContextBudget resolves the utility model's context window.
// Falls back to DefaultBudget if the model isn't in the catalog.
func (s *Service) getUtilityContextBudget(ctx context.Context, userID string, teamID *string) int {
cfg, err := s.resolver.GetConfig(ctx, roles.RoleUtility, userID, teamID)
if err != nil || cfg == nil || cfg.Primary == nil {
return DefaultBudget
}
// Look up the primary model in catalog
entry, err := s.stores.Catalog.GetByModelID(ctx, cfg.Primary.ProviderConfigID, cfg.Primary.ModelID)
if err == nil && entry.Capabilities.MaxContext > 0 {
return entry.Capabilities.MaxContext
}
// Try any-provider lookup (model may be synced under a different config)
entry, err = s.stores.Catalog.GetByModelIDAny(ctx, cfg.Primary.ModelID)
if err == nil && entry.Capabilities.MaxContext > 0 {
return entry.Capabilities.MaxContext
}
return DefaultBudget
}
// ── Rate Limit Check ────────────────────────
// CheckRateLimit checks the utility rate limit for org-funded calls.
// Returns nil if within limits, an error with a descriptive message otherwise.
func (s *Service) CheckRateLimit(ctx context.Context, userID string) error {
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
limit := 20
settings, err := s.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
if err == nil {
if v, ok := settings["value"]; ok {
switch n := v.(type) {
case float64:
limit = int(n)
case int:
limit = n
}
}
}
if limit <= 0 {
return nil // unlimited
}
since := time.Now().Add(-1 * time.Hour)
count, err := s.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
if err != nil {
log.Printf("⚠ Rate limit check failed: %v", err)
return nil // fail open — don't block on DB errors
}
if count >= limit {
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
}
return nil
}
// ── Helpers ─────────────────────────────────
// GetUserTeamID returns the user's first team ID (for role resolution).
func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string {
teamID, _ := s.stores.Teams.GetFirstTeamIDForUser(ctx, userID)
if teamID == "" {
return nil
}
return &teamID
}
func (s *Service) logUsage(ctx context.Context, channelID, userID, trigger string, result *roles.CompletionResult) {
role := roles.RoleUtility
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &result.ConfigID,
ProviderScope: result.ProviderScope,
ModelID: result.Model,
Role: &role,
PromptTokens: result.InputTokens,
CompletionTokens: result.OutputTokens,
CacheCreationTokens: result.CacheCreationTokens,
CacheReadTokens: result.CacheReadTokens,
}
// Calculate cost from pricing
pricing, err := s.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
if err == nil && pricing != nil {
if pricing.InputPerM != nil {
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
entry.CostInput = &costIn
}
if pricing.OutputPerM != nil {
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
entry.CostOutput = &costOut
}
}
if err := s.stores.Usage.Log(ctx, entry); err != nil {
log.Printf("⚠ Failed to log compaction usage: %v", err)
}
}