mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
feat(compact): silent compaction with status line and orphan repair
Compaction no longer streams the summary into the chat. The spinner shows while compacting and users can queue prompts that fire after completion. The compaction info appears in the status line with ctrl+o to expand. Orphaned tool_result blocks in the preserved tail are stripped to prevent Anthropic rejection.
This commit is contained in:
parent
b6529cf5c4
commit
daaa062c68
4 changed files with 146 additions and 19 deletions
|
|
@ -2363,33 +2363,36 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
|
|||
if auto {
|
||||
i.spin.StartFixed("condensing history")
|
||||
i.autoCompacting = true
|
||||
i.statusOK = "condensing history… (esc to cancel)"
|
||||
} else {
|
||||
i.spin.Start()
|
||||
i.statusOK = "compacting..."
|
||||
i.spin.StartFixed("compacting")
|
||||
}
|
||||
i.cancelTurn = cancel
|
||||
i.statusErr = ""
|
||||
i.streaming.Reset()
|
||||
i.streamOn = true
|
||||
i.statusOK = ""
|
||||
// Do NOT set streamOn: the summary text should not be visible
|
||||
// in the chat while compacting. The user just sees the spinner
|
||||
// and can keep typing / queue prompts.
|
||||
i.scrollOffset = 0
|
||||
i.helpBlock = nil
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
|
||||
go func() {
|
||||
sink := func(delta string) {
|
||||
i.mu.Lock()
|
||||
i.streaming.WriteString(delta)
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
}
|
||||
// Sink discards deltas — we don't stream the summary to the UI.
|
||||
sink := func(delta string) {}
|
||||
summary, err := i.agent.Compact(ctx, 4, sink)
|
||||
_ = summary
|
||||
i.mu.Lock()
|
||||
i.busy = false
|
||||
i.resetStreamingStateLocked()
|
||||
i.cancelTurn = nil
|
||||
i.autoCompacting = false
|
||||
|
||||
// Drain the queue: if the user typed a prompt while compacting,
|
||||
// fire it now that the transcript is clean.
|
||||
var next string
|
||||
var hasNext bool
|
||||
|
||||
switch {
|
||||
case err != nil && ctx.Err() != nil:
|
||||
i.statusErr = ""
|
||||
|
|
@ -2398,22 +2401,44 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
|
|||
} else {
|
||||
i.statusOK = "compaction cancelled"
|
||||
}
|
||||
i.queued = nil // drop queue on cancel
|
||||
case err != nil:
|
||||
i.statusErr = "compaction failed: " + err.Error()
|
||||
i.statusOK = ""
|
||||
i.queued = nil // drop queue on error
|
||||
default:
|
||||
i.statusErr = ""
|
||||
i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
|
||||
i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
|
||||
// Read token count from the compaction message meta.
|
||||
tokens := ""
|
||||
msgs := i.agent.Messages()
|
||||
if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
|
||||
tokens = msgs[0].Meta["tokens_before"]
|
||||
}
|
||||
if tokens != "" {
|
||||
i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
|
||||
} else {
|
||||
i.statusOK = "compacted (ctrl+o to expand)"
|
||||
}
|
||||
i.lastCtxInput = 0
|
||||
i.toolCalls = map[string]*tui.ToolCallView{}
|
||||
i.toolOrder = nil
|
||||
// Transcript was rewritten in place — purge the per-message
|
||||
// render cache so stale entries keyed on the old messages
|
||||
// don't linger.
|
||||
i.view.InvalidateRenderCache()
|
||||
// Pop queued prompt if any.
|
||||
if len(i.queued) > 0 {
|
||||
next, i.queued = i.queued[0], i.queued[1:]
|
||||
hasNext = true
|
||||
}
|
||||
}
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
|
||||
if hasNext {
|
||||
p := i.runCtx
|
||||
if p == nil {
|
||||
p = context.Background()
|
||||
}
|
||||
i.startTurn(p, next)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package core
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -82,6 +83,9 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
|
|||
return "", fmt.Errorf("empty summary from model")
|
||||
}
|
||||
|
||||
// Estimate token count before compaction (rough: 1 token ~ 4 chars).
|
||||
tokensBefore := len(transcript) / 4
|
||||
|
||||
// Replace transcript: one synthetic user message with the summary,
|
||||
// followed by the preserved tail (if any).
|
||||
synthetic := provider.Message{
|
||||
|
|
@ -90,9 +94,19 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
|
|||
provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
|
||||
},
|
||||
Time: time.Now(),
|
||||
Meta: map[string]string{
|
||||
"compaction": "true",
|
||||
"tokens_before": strconv.Itoa(tokensBefore),
|
||||
},
|
||||
}
|
||||
|
||||
tail := msgs[len(msgs)-keepTail:]
|
||||
// Repair the tail: remove orphaned tool_result blocks whose
|
||||
// matching tool_use was in the compacted (now-removed) portion.
|
||||
// Anthropic rejects transcripts where a tool_result references
|
||||
// a tool_use ID that doesn't exist.
|
||||
tail = repairOrphanedToolResults(tail)
|
||||
|
||||
next := make([]provider.Message, 0, 1+len(tail))
|
||||
next = append(next, synthetic)
|
||||
next = append(next, tail...)
|
||||
|
|
@ -104,6 +118,44 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
|
|||
return summary, nil
|
||||
}
|
||||
|
||||
// repairOrphanedToolResults removes tool_result content blocks (and
|
||||
// entire messages that become empty) when the matching tool_use ID
|
||||
// does not appear anywhere in the given messages. This happens after
|
||||
// compaction when the tail preserves a tool_result but the tool_use
|
||||
// that produced it was summarized away.
|
||||
func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
|
||||
// Collect all tool_use IDs present in the messages.
|
||||
useIDs := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
for _, c := range m.Content {
|
||||
if tc, ok := c.(provider.ToolCallBlock); ok {
|
||||
useIDs[tc.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out tool_result blocks referencing missing tool_use IDs.
|
||||
out := make([]provider.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
var filtered []provider.Content
|
||||
for _, c := range m.Content {
|
||||
if tr, ok := c.(provider.ToolResultBlock); ok {
|
||||
if !useIDs[tr.CallID] {
|
||||
continue // orphaned
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
copy := m
|
||||
copy.Content = filtered
|
||||
out = append(out, copy)
|
||||
}
|
||||
// Drop messages that became empty after filtering.
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// serializeTranscript renders a list of provider.Message into a plain
|
||||
// text transcript the summarization model can read without trying to
|
||||
// continue the conversation.
|
||||
|
|
|
|||
|
|
@ -62,9 +62,10 @@ func (ToolResultBlock) isContent() {}
|
|||
|
||||
// Message is a single turn in the conversation.
|
||||
type Message struct {
|
||||
Role Role `json:"role"`
|
||||
Content []Content `json:"content"`
|
||||
Time time.Time `json:"time"`
|
||||
Role Role `json:"role"`
|
||||
Content []Content `json:"content"`
|
||||
Time time.Time `json:"time"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Tool is a tool definition advertised to the LLM.
|
||||
|
|
|
|||
|
|
@ -403,6 +403,17 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
|
|||
|
||||
func (v *View) renderMessage(m provider.Message, width int) []string {
|
||||
var lines []string
|
||||
|
||||
// Compaction summary: render as a single muted line at the end
|
||||
// of the chat instead of as a user message.
|
||||
if m.Meta["compaction"] == "true" {
|
||||
if v.ExpandAll {
|
||||
return v.renderCompactionBlock(m, width)
|
||||
}
|
||||
// Collapsed: skip entirely. The status bar shows the info.
|
||||
return nil
|
||||
}
|
||||
|
||||
switch m.Role {
|
||||
case provider.RoleUser:
|
||||
header := v.Theme.FG256(v.Theme.User, "▍ you")
|
||||
|
|
@ -1279,6 +1290,44 @@ func truncateLines(s string, n int) string {
|
|||
return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
|
||||
}
|
||||
|
||||
// renderCompactionBlock renders a compaction summary as a distinct
|
||||
// visual block in the chat. When collapsed it shows a one-line label
|
||||
// with the pre-compaction token count; when expanded (ctrl+o) it
|
||||
// shows the full summary text.
|
||||
func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
|
||||
th := v.Theme
|
||||
const indent = " "
|
||||
|
||||
tokens := m.Meta["tokens_before"]
|
||||
if tokens == "" {
|
||||
tokens = "?"
|
||||
}
|
||||
|
||||
if v.ExpandAll {
|
||||
var lines []string
|
||||
header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
|
||||
lines = append(lines, indent+header)
|
||||
lines = append(lines, "")
|
||||
for _, c := range m.Content {
|
||||
if tb, ok := c.(provider.TextBlock); ok {
|
||||
text := tb.Text
|
||||
if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
|
||||
text = text[idx+2:]
|
||||
}
|
||||
md := RenderMarkdown(text, th, width-4)
|
||||
for _, l := range strings.Split(md, "\n") {
|
||||
lines = append(lines, indent+l)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// Collapsed: single line, no banner.
|
||||
line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
|
||||
return []string{indent + line}
|
||||
}
|
||||
|
||||
// StatusBarParams groups the many bits of state the status bar needs.
|
||||
// Grew from a flat argument list once we settled on the layout.
|
||||
type StatusBarParams struct {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue