Merge pull request #24 from hobbyistlabs-coder/feature/background-summarization-10072916128906298897

 Bolt: Offload summarization to background worker
This commit is contained in:
hobbyistlabs-coder 2026-03-13 19:00:43 -04:00 committed by GitHub
commit 2b566cf1ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 98 additions and 6 deletions

View file

@ -21,7 +21,7 @@ This document outlines a series of tasks to improve the main loop of the agentic
## Phase 3: Performance & Latency ## Phase 3: Performance & Latency
* [x] **Concurrent Message Processing:** Evaluate introducing a worker pool or goroutines to process independent user requests concurrently without blocking the entire agent instance. * [x] **Concurrent Message Processing:** Evaluate introducing a worker pool or goroutines to process independent user requests concurrently without blocking the entire agent instance.
* [ ] **Background Summarization:** Offload `maybeSummarize` and context compression to an asynchronous worker. Instead of blocking the main thread, the worker outputs its execution trace and compressed context to a structured `/logs/{session}/{subagent}/` directory to provide a persistent audit trail while keeping the loop responsive. * [x] **Background Summarization:** Offload `maybeSummarize` and context compression to an asynchronous worker. Instead of blocking the main thread, the worker outputs its execution trace and compressed context to a structured `/logs/{session}/{subagent}/` directory to provide a persistent audit trail while keeping the loop responsive.
* [ ] **Streaming Responses:** Implement streaming LLM token generation directly to the `bus.PublishOutbound` instead of waiting for full generation. * [ ] **Streaming Responses:** Implement streaming LLM token generation directly to the `bus.PublishOutbound` instead of waiting for full generation.
## Phase 4: Features ## Phase 4: Features

View file

@ -27,6 +27,7 @@ type AgentLoop struct {
state *state.Manager state *state.Manager
running atomic.Bool running atomic.Bool
summarizing sync.Map summarizing sync.Map
summaryJobs chan summaryJob
wg sync.WaitGroup wg sync.WaitGroup
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
@ -37,6 +38,11 @@ type AgentLoop struct {
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
type summaryJob struct {
agent *AgentInstance
sessionKey string
}
type processOptions struct { type processOptions struct {
SessionKey string // Session identifier for history/context SessionKey string // Session identifier for history/context
Channel string // Target channel for tool execution Channel string // Target channel for tool execution

View file

@ -52,6 +52,7 @@ func NewAgentLoop(
registry: registry, registry: registry,
state: stateManager, state: stateManager,
summarizing: sync.Map{}, summarizing: sync.Map{},
summaryJobs: make(chan summaryJob, 100),
fallback: fallbackChain, fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
} }
@ -195,6 +196,25 @@ func (al *AgentLoop) Run(ctx context.Context) error {
return err return err
} }
// Start background summarization worker
al.wg.Add(1)
go func() {
defer al.wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-al.summaryJobs:
if !ok {
return
}
logger.Debug("Memory threshold reached. Optimizing conversation history...")
al.summarizeSession(job.agent, job.sessionKey)
al.summarizing.Delete(job.agent.ID + ":" + job.sessionKey)
}
}
}()
for al.running.Load() { for al.running.Load() {
select { select {
case <-ctx.Done(): case <-ctx.Done():

View file

@ -8,7 +8,10 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -26,11 +29,15 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() { // Send to background worker queue
defer al.summarizing.Delete(summarizeKey) select {
logger.Debug("Memory threshold reached. Optimizing conversation history...") case al.summaryJobs <- summaryJob{agent: agent, sessionKey: sessionKey}:
al.summarizeSession(agent, sessionKey) // job accepted
}() default:
// Worker queue is full, delete from map so it can be retried later
logger.WarnCF("agent", "Summarization worker queue is full, skipping summarization", map[string]any{"session_key": sessionKey})
al.summarizing.Delete(summarizeKey)
}
} }
} }
} }
@ -43,6 +50,23 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
return return
} }
// Create logging directory: /logs/{session_id}/summarizer/
logDir := filepath.Join("logs", sessionKey, "summarizer")
if err := os.MkdirAll(logDir, 0755); err != nil {
logger.WarnCF("agent", "Failed to create summarizer log directory", map[string]any{"error": err.Error()})
}
// Dump input context
if b, err := json.MarshalIndent(history, "", " "); err == nil {
_ = os.WriteFile(filepath.Join(logDir, "input_context.json"), b, 0644)
}
traceFile, _ := os.OpenFile(filepath.Join(logDir, "trace.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if traceFile != nil {
defer traceFile.Close()
fmt.Fprintf(traceFile, "[%s] Starting summarization for session: %s\n", time.Now().Format(time.RFC3339), sessionKey)
}
// Keep system prompt (usually [0]) and the very last message (user's trigger) // Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation* // We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation // Assuming [0] is system, [1:] is conversation
@ -101,6 +125,23 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
return return
} }
// Create logging directory: /logs/{session_id}/summarizer/
logDir := filepath.Join("logs", sessionKey, "summarizer")
if err := os.MkdirAll(logDir, 0755); err != nil {
logger.WarnCF("agent", "Failed to create summarizer log directory", map[string]any{"error": err.Error()})
}
// Dump input context
if b, err := json.MarshalIndent(history, "", " "); err == nil {
_ = os.WriteFile(filepath.Join(logDir, "input_context.json"), b, 0644)
}
traceFile, _ := os.OpenFile(filepath.Join(logDir, "trace.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if traceFile != nil {
defer traceFile.Close()
fmt.Fprintf(traceFile, "[%s] Starting summarization for session: %s\n", time.Now().Format(time.RFC3339), sessionKey)
}
toSummarize := history[:len(history)-4] toSummarize := history[:len(history)-4]
// Oversized Message Guard // Oversized Message Guard
@ -115,12 +156,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
msgTokens := len(m.Content) / 2 msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens { if msgTokens > maxMessageTokens {
omitted = true omitted = true
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] Omitting oversized message from %s (length: %d)\n", time.Now().Format(time.RFC3339), m.Role, len(m.Content))
}
continue continue
} }
validMessages = append(validMessages, m) validMessages = append(validMessages, m)
} }
if len(validMessages) == 0 { if len(validMessages) == 0 {
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] No valid messages to summarize.\n", time.Now().Format(time.RFC3339))
}
return return
} }
@ -150,13 +197,23 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s2, s2,
) )
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] Merging multi-part summaries...\n", time.Now().Format(time.RFC3339))
}
resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
if err == nil && resp.Content != "" { if err == nil && resp.Content != "" {
finalSummary = resp.Content finalSummary = resp.Content
} else { } else {
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] LLM merge failed: %v. Falling back to concatenation.\n", time.Now().Format(time.RFC3339), err)
}
finalSummary = s1 + " " + s2 finalSummary = s1 + " " + s2
} }
} else { } else {
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] Summarizing single batch...\n", time.Now().Format(time.RFC3339))
}
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
} }
@ -165,9 +222,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
} }
if finalSummary != "" { if finalSummary != "" {
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] Summarization complete. Saving session.\n", time.Now().Format(time.RFC3339))
}
_ = os.WriteFile(filepath.Join(logDir, "result_summary.md"), []byte(finalSummary), 0644)
agent.Sessions.SetSummary(sessionKey, finalSummary) agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4) agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey) agent.Sessions.Save(sessionKey)
} else {
if traceFile != nil {
fmt.Fprintf(traceFile, "[%s] Final summary is empty, skipping save.\n", time.Now().Format(time.RFC3339))
}
} }
} }