agent: bound markdown and compaction caches

This commit is contained in:
ParthSareen 2026-06-11 21:02:40 -07:00
parent 72d18c99af
commit f417732279
4 changed files with 117 additions and 7 deletions

View file

@ -15,6 +15,8 @@ const (
defaultCompactionThreshold = 0.8
compactionSummaryMessagePrefix = "Conversation summary:\n"
maxCompactionSummaryBytes = 16 * 1024
compactionSummaryTruncated = "\n\n[summary truncated]"
)
type Compactor interface {
@ -91,7 +93,7 @@ func (c *SimpleCompactor) MaybeCompact(ctx context.Context, req CompactionReques
result.Reason = err.Error()
return result, err
}
summary = strings.TrimSpace(summary)
summary = truncateCompactionSummary(strings.TrimSpace(summary))
if summary == "" {
result.Reason = "summary was empty"
return result, nil
@ -201,6 +203,24 @@ func (c *SimpleCompactor) summarize(ctx context.Context, req CompactionRequest,
return summary.String(), nil
}
func truncateCompactionSummary(summary string) string {
if len(summary) <= maxCompactionSummaryBytes {
return summary
}
limit := maxCompactionSummaryBytes - len(compactionSummaryTruncated)
if limit < 0 {
limit = 0
}
var b strings.Builder
for _, r := range summary {
if b.Len()+len(string(r)) > limit {
break
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String()) + compactionSummaryTruncated
}
func estimateCompactionTokens(text string) int {
text = strings.TrimSpace(text)
if text == "" {

View file

@ -85,6 +85,50 @@ func TestSimpleCompactorSummarizesOldMessages(t *testing.T) {
}
}
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
longSummary := strings.Repeat("x", maxCompactionSummaryBytes+1024)
client := &fakeClient{
responses: [][]api.ChatResponse{{
{Message: api.Message{Role: "assistant", Content: longSummary}},
}},
}
store := &compactionStore{}
compactor := NewSimpleCompactor(client, store, CompactionOptions{
ContextWindowTokens: 100,
KeepUserTurns: 1,
Threshold: 0.5,
})
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
ChatID: "chat-1",
Model: "model",
Messages: []api.Message{
{Role: "user", Content: "old one"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "recent one"},
},
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 75}},
})
if err != nil {
t.Fatal(err)
}
if !result.Compacted {
t.Fatal("expected compaction")
}
if len(result.Summary) > maxCompactionSummaryBytes {
t.Fatalf("summary bytes = %d, want <= %d", len(result.Summary), maxCompactionSummaryBytes)
}
if !strings.HasSuffix(result.Summary, compactionSummaryTruncated) {
t.Fatalf("summary missing truncation marker")
}
if store.summary != result.Summary {
t.Fatalf("stored summary mismatch")
}
if !strings.Contains(result.Messages[0].Content, compactionSummaryTruncated) {
t.Fatalf("compacted message missing truncation marker: %#v", result.Messages)
}
}
func TestSimpleCompactorSkipsBelowThreshold(t *testing.T) {
client := &fakeClient{}
compactor := NewSimpleCompactor(client, nil, CompactionOptions{

View file

@ -17,7 +17,19 @@ var markdownLinkPattern = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)
var markdownTableSeparatorPattern = regexp.MustCompile(`^:?-{3,}:?$`)
var chatMarkdownRenderers sync.Map
const maxMarkdownRendererCacheEntries = 8
var chatMarkdownRenderers = newMarkdownRendererCache()
type markdownRendererCache struct {
mu sync.Mutex
renderers map[int]*cachedMarkdownRenderer
order []int
}
func newMarkdownRendererCache() *markdownRendererCache {
return &markdownRendererCache{renderers: make(map[int]*cachedMarkdownRenderer)}
}
type cachedMarkdownRenderer struct {
renderer *glamour.TermRenderer
@ -461,10 +473,15 @@ func widestColumn(widths []int) int {
}
func markdownRendererForWidth(width int) (*cachedMarkdownRenderer, error) {
if cached, ok := chatMarkdownRenderers.Load(width); ok {
return cached.(*cachedMarkdownRenderer), nil
}
return chatMarkdownRenderers.renderer(width)
}
func (c *markdownRendererCache) renderer(width int) (*cachedMarkdownRenderer, error) {
c.mu.Lock()
defer c.mu.Unlock()
if cached, ok := c.renderers[width]; ok {
return cached, nil
}
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(compactMarkdownStyle()),
glamour.WithWordWrap(width),
@ -475,8 +492,20 @@ func markdownRendererForWidth(width int) (*cachedMarkdownRenderer, error) {
return nil, err
}
cached := &cachedMarkdownRenderer{renderer: renderer}
actual, _ := chatMarkdownRenderers.LoadOrStore(width, cached)
return actual.(*cachedMarkdownRenderer), nil
if len(c.order) >= maxMarkdownRendererCacheEntries {
evict := c.order[0]
c.order = c.order[1:]
delete(c.renderers, evict)
}
c.renderers[width] = cached
c.order = append(c.order, width)
return cached, nil
}
func (c *markdownRendererCache) len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.renderers)
}
func renderMarkdownDiffFences(markdown string, width int) (string, bool) {

View file

@ -21,6 +21,23 @@ func TestChatMarkdownRendersAssistantAndSystemOutput(t *testing.T) {
}
}
func TestMarkdownRendererCacheIsBounded(t *testing.T) {
previous := chatMarkdownRenderers
chatMarkdownRenderers = newMarkdownRendererCache()
defer func() {
chatMarkdownRenderers = previous
}()
for i := 0; i < maxMarkdownRendererCacheEntries+4; i++ {
if _, err := markdownRendererForWidth(40 + i); err != nil {
t.Fatal(err)
}
}
if got := chatMarkdownRenderers.len(); got != maxMarkdownRendererCacheEntries {
t.Fatalf("cache size = %d, want %d", got, maxMarkdownRendererCacheEntries)
}
}
func TestChatMarkdownExposesLinks(t *testing.T) {
m := chatModel{width: 100, height: 30}
m.entries = []chatEntry{{