mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
The v3 pipeline compacts session history mid-loop (prune_stage + final-request guard) but only mutates the run's message buffer, never the session store. Each turn reloads full history and re-compacts from scratch: message_tokens climb 129k->156k across turns while every turn compacts back down to ~60k. The lossy compaction differs per run, degrading the agent. The same missing persistence stalls episodic memory: the cumulative compaction count never advances, so the episodic worker's idempotency key (sessionKey:count) is pinned and every cycle after the first is skipped. Observed on live traffic: 8 run.completed since deploy, 0 new episodic. Fixes, all reusing existing machinery (no new store methods, no migrations): - Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching the legacy v2 path) instead of the per-run counter that resets to 0. - Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into maybeSummarize; under pressure it lowers the trigger to a unit-aligned threshold (compactionInputCap - overhead, same MaxRequestShare the guard uses) so the compaction is PERSISTED via the existing TruncateHistory + IncrementCompaction path. Defensive floor prevents over-compaction on pathological config; tool-result-only bloat still skips (history-only). - Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup key advances per compaction cycle instead of swallowing rapid same-session turns within the 5m TTL. Tests: episodic compaction, maybe_summarize pressure, request budget. go build (PG + sqliteonly), go vet, go test -race all green.
199 lines
6.9 KiB
Go
199 lines
6.9 KiB
Go
package consolidation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
|
)
|
|
|
|
// episodicWorker handles session.completed events → creates episodic summaries.
|
|
type episodicWorker struct {
|
|
store store.EpisodicStore
|
|
sessions store.SessionCoreStore // for reading session messages during summarization
|
|
systemConfigs store.SystemConfigStore // per-tenant provider config
|
|
registry *providers.Registry // provider resolution
|
|
eventBus eventbus.DomainEventBus
|
|
alertDeps bgalert.AlertDeps
|
|
usageCaps *usagecaps.Service
|
|
agents store.AgentCRUDStore // resolves per-agent request budget for the preflight guard
|
|
}
|
|
|
|
// resolveProvider delegates to shared background provider resolution.
|
|
func (w *episodicWorker) resolveProvider(ctx context.Context, tenantID uuid.UUID) (providers.Provider, string) {
|
|
return providerresolve.ResolveBackgroundProvider(ctx, tenantID, w.registry, w.systemConfigs)
|
|
}
|
|
|
|
// Handle processes a session.completed event into an episodic summary.
|
|
func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent) error {
|
|
slog.Debug("episodic: received session.completed",
|
|
"agent", event.AgentID, "user", event.UserID,
|
|
"source", event.SourceID, "payload_type", fmt.Sprintf("%T", event.Payload))
|
|
|
|
payload, ok := event.Payload.(*eventbus.SessionCompletedPayload)
|
|
if !ok {
|
|
return fmt.Errorf("episodic: unexpected payload type %T", event.Payload)
|
|
}
|
|
|
|
// Parse tenant/agent IDs up front so we fail fast on bad input instead of
|
|
// leaking into a PG error or panicking via uuid.MustParse later on.
|
|
tenantUUID, err := uuid.Parse(event.TenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("episodic: invalid tenant_id %q: %w", event.TenantID, err)
|
|
}
|
|
// Inject tenant context so store queries and bgalert scope correctly.
|
|
ctx = store.WithTenantID(ctx, tenantUUID)
|
|
agentUUID, err := uuid.Parse(event.AgentID)
|
|
if err != nil {
|
|
return fmt.Errorf("episodic: invalid agent_id %q: %w", event.AgentID, err)
|
|
}
|
|
ctx = store.WithAgentID(ctx, agentUUID)
|
|
// Wire the calling agent's request budget so nested LLM summarization calls
|
|
// pass the agent-only preflight guard instead of failing closed with an
|
|
// AgentBudgetWiringError (purpose=episodic-summary).
|
|
ctx = withAgentRequestBudget(ctx, w.agents, agentUUID, "episodic-summary")
|
|
|
|
// Build source_id for idempotency
|
|
sourceID := fmt.Sprintf("%s:%d", payload.SessionKey, payload.CompactionCount)
|
|
exists, err := w.store.ExistsBySourceID(ctx, agentUUID.String(), event.UserID, sourceID)
|
|
if err != nil {
|
|
return fmt.Errorf("episodic: check source_id: %w", err)
|
|
}
|
|
if exists {
|
|
slog.Debug("episodic: skipping duplicate", "source_id", sourceID)
|
|
return nil
|
|
}
|
|
|
|
// Use compaction summary if available, else call LLM
|
|
summary := payload.Summary
|
|
if summary == "" {
|
|
provider, model := w.resolveProvider(ctx, tenantUUID)
|
|
if provider != nil {
|
|
summary, err = w.summarizeSession(ctx, provider, model, payload)
|
|
if err != nil {
|
|
bgalert.ReportProviderError(ctx, w.alertDeps, "episodic", err)
|
|
return fmt.Errorf("episodic: summarize: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if summary == "" {
|
|
provider, _ := w.resolveProvider(ctx, tenantUUID)
|
|
slog.Warn("episodic: no summary available, skipping", "session", payload.SessionKey,
|
|
"compaction_summary_empty", payload.Summary == "", "provider_nil", provider == nil)
|
|
return nil
|
|
}
|
|
slog.Debug("episodic: creating summary", "session", payload.SessionKey, "summary_len", len(summary))
|
|
|
|
// Create episodic summary
|
|
l0 := generateL0Abstract(summary)
|
|
entities := extractEntityNames(summary)
|
|
expiresAt := time.Now().UTC().Add(90 * 24 * time.Hour)
|
|
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantUUID,
|
|
AgentID: agentUUID,
|
|
UserID: event.UserID,
|
|
SessionKey: payload.SessionKey,
|
|
Summary: summary,
|
|
KeyTopics: entities,
|
|
TurnCount: payload.MessageCount,
|
|
TokenCount: payload.TokensUsed,
|
|
L0Abstract: l0,
|
|
SourceID: sourceID,
|
|
SourceType: "session",
|
|
ExpiresAt: &expiresAt,
|
|
}
|
|
if err := w.store.Create(ctx, ep); err != nil {
|
|
return fmt.Errorf("episodic: create: %w", err)
|
|
}
|
|
|
|
// Publish episodic.created event for downstream semantic extraction
|
|
w.eventBus.Publish(eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
SourceID: ep.ID.String(),
|
|
TenantID: event.TenantID,
|
|
AgentID: event.AgentID,
|
|
UserID: event.UserID,
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
EpisodicID: ep.ID.String(),
|
|
SessionKey: payload.SessionKey,
|
|
Summary: summary,
|
|
KeyTopics: entities,
|
|
KeyEntities: entities,
|
|
},
|
|
})
|
|
|
|
slog.Info("episodic: created summary", "session", payload.SessionKey, "l0_len", len(l0))
|
|
return nil
|
|
}
|
|
|
|
// summarizeSession reads actual session messages and calls LLM to summarize.
|
|
func (w *episodicWorker) summarizeSession(ctx context.Context, provider providers.Provider, model string, payload *eventbus.SessionCompletedPayload) (string, error) {
|
|
// Try reading session messages for a real summary.
|
|
if w.sessions != nil {
|
|
messages := w.sessions.GetHistory(ctx, payload.SessionKey)
|
|
if len(messages) > 0 {
|
|
return w.summarizeFromMessages(ctx, provider, model, messages)
|
|
}
|
|
// Messages may have been compacted away — try existing session summary.
|
|
if summary := w.sessions.GetSummary(ctx, payload.SessionKey); summary != "" {
|
|
return summary, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no messages or summary available for session %s", payload.SessionKey)
|
|
}
|
|
|
|
// summarizeFromMessages builds a conversation excerpt and calls LLM.
|
|
func (w *episodicWorker) summarizeFromMessages(ctx context.Context, provider providers.Provider, model string, messages []providers.Message) (string, error) {
|
|
var sb strings.Builder
|
|
for _, m := range messages {
|
|
if m.Role == "system" {
|
|
continue
|
|
}
|
|
content := m.Content
|
|
// Rune-safe truncation to avoid corrupting UTF-8 (CJK, emoji).
|
|
if runes := []rune(content); len(runes) > 500 {
|
|
content = string(runes[:500]) + "..."
|
|
}
|
|
sb.WriteString(m.Role)
|
|
sb.WriteString(": ")
|
|
sb.WriteString(content)
|
|
sb.WriteByte('\n')
|
|
if sb.Len() > 8000 {
|
|
sb.WriteString("...(truncated)\n")
|
|
break
|
|
}
|
|
}
|
|
|
|
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
req := providers.ChatRequest{
|
|
Messages: []providers.Message{
|
|
{Role: "system", Content: summarizationPrompt},
|
|
{Role: "user", Content: sb.String()},
|
|
},
|
|
Model: model,
|
|
Options: map[string]any{"max_tokens": 1024, "temperature": 0.3},
|
|
}
|
|
resp, err := w.usageCaps.Chat(sctx, provider, req, usagecaps.ChatOptions{
|
|
ModelID: model,
|
|
Purpose: "episodic-summary",
|
|
MaxOutputTokens: 1024,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
bgalert.ClearProviderError(ctx, w.alertDeps.SystemConfigs)
|
|
return resp.Content, nil
|
|
}
|