bots-garden/mini-mepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

compact.go · 307 lines · 10.8 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 4h ago1// Package compact keeps the conversation history inside the model's context
2// window. When the history grows past a threshold, the OLD question turns are
3// replaced by ONE summary written by the model itself, and the last N turns
4// stay raw — the hybrid of CONTEXT_WINDOW.md § 2.e.
5//
6// Why it exists: nothing in the agent ever shortens `messages`. Measured with
7// the fake engine of part 03, the history sent to the model grew 2 → 5 → 7 → 9
8// messages over four requests, and every `bash` output (up to maxOutput
9// characters) stays until /quit. On a local model the window is fixed at load
10// time, so a long session first shows up as a false watchdog stall, then as a
11// 400 from the engine.
12//
13// The package never calls the model directly: it builds the summary request
14// and hands it to a Summarizer (engine.Summarize in production, a function
15// returning a fixed text in tests). It knows nothing about the screen either —
16// the report line is the agent's job.
17package compact
18
19import (
20 "context"
21 "encoding/json"
22 "errors"
23 "fmt"
24 "slices"
25 "strings"
26 "time"
27
28 "mm/internal/config"
29
30 "github.com/firebase/genkit/go/ai"
31)
32
33// Summarizer answers a request built by this package — a system message, the
34// old turns, then the prompt as a user message — with plain text.
35type Summarizer func(ctx context.Context, request []*ai.Message) (string, error)
36
37// Result describes one compression, for the one-line report.
38type Result struct {
39 Messages []*ai.Message
40 Compressed int // messages replaced by the summary pair
41 Kept int // raw messages kept after the summary (system excluded)
42 Before int // estimated tokens before
43 After int // estimated tokens after
44 Elapsed time.Duration
45}
46
47// Report is the one-line account of a compression, shared by the two front
48// ends so the terminal and the editor say the same thing:
49//
50// compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)
51func (r Result) Report() string {
52 return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",
53 r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed))
54}
55
56// kilo prints a token count the way the banner does: 18.2k, not 18234.
57func kilo(n int) string {
58 if n < 1000 {
59 return fmt.Sprintf("%d", n)
60 }
61 return fmt.Sprintf("%.1fk", float64(n)/1000)
62}
63
64// seconds prints a duration with one decimal under ten seconds, none above.
65func seconds(d time.Duration) string {
66 if d < 10*time.Second {
67 return fmt.Sprintf("%.1fs", d.Seconds())
68 }
69 return fmt.Sprintf("%ds", int(d.Seconds()))
70}
71
72// ErrNothingToCompact is returned when the history holds no turn older than
73// the ones to keep. It is not a failure: the threshold can be reached inside a
74// single long turn, and there is then nothing to summarise yet.
75var ErrNothingToCompact = errors.New("nothing to compact")
76
77// metaKey marks the two messages a compression inserts, so that a later
78// compression recognises an earlier summary (and merges it instead of
79// summarising it a second time — detail decays exponentially otherwise).
80const metaKey = "compact"
81
82// Decision says whether to compress before the next question, and on what
83// grounds. Tokens is the figure that was compared to the threshold; it is
84// reported even when nothing triggers, for the /context-style displays.
85type Decision struct {
86 Compact bool
87 Tokens int
88 Reason string // "tokens", "messages", or "" when nothing triggered
89}
90
91// Decide applies the automatic trigger.
92//
93// `measured` is the input-token count the engine reported for the last call
94// to the model (0 when it reported nothing, as the fake engine does). The
95// larger of it and the local estimate is used: the measure sees what the
96// estimate cannot (the tools' JSON, the chat template), but it lags one call
97// behind and dies with the compression that made it stale — the caller
98// forgets it after a successful compression.
99//
100// `window` is the served context size as far as the agent knows it: the
101// yaml's `contextWindow`, or what the provider's probe learned from the server
102// (llama-server's /props) — see engine.Engine.ContextWindow. 0 = unknown, and
103// then only MaxMessages can trigger.
104func Decide(msgs []*ai.Message, measured, window int, cfg config.ContextConfig) Decision {
105 d := Decision{Tokens: max(Estimate(msgs), measured)}
106 if !cfg.Enabled {
107 return d
108 }
109 switch {
110 // Cross-multiplied to stay in integers: tokens ≥ window × threshold / 100.
111 case window > 0 && d.Tokens*100 >= window*cfg.Threshold:
112 d.Compact, d.Reason = true, "tokens"
113 case cfg.MaxMessages > 0 && len(msgs) >= cfg.MaxMessages:
114 d.Compact, d.Reason = true, "messages"
115 }
116 return d
117}
118
119// Compact replaces every turn but the last cfg.KeepLastTurns by a summary pair
120// (a user message carrying the notes, a short model acknowledgement), inserted
121// right after the system prompt. The kept turns are reused as they are, so the
122// tool_call/tool_result pairs inside them cannot be split; the cut itself falls
123// on a turn boundary, never on a message count.
124//
125// On any error the caller's history is left untouched: the returned Result is
126// empty and `msgs` was never modified.
127func Compact(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer) (Result, error) {
128 start := time.Now()
129
130 head, turns := Split(msgs)
131 keep := max(cfg.KeepLastTurns, 1)
132 if len(turns) <= keep {
133 return Result{}, ErrNothingToCompact
134 }
135 old, recent := turns[:len(turns)-keep], turns[len(turns)-keep:]
136
137 // An earlier summary that is the only old turn: re-summarising a summary
138 // loses detail and gains nothing.
139 merge := IsSummary(old[0][0])
140 if merge && len(old) == 1 {
141 return Result{}, ErrNothingToCompact
142 }
143
144 oldMsgs := flatten(old)
145 text, err := summarize(ctx, Request(oldMsgs, cfg.Prompt, merge))
146 if err != nil {
147 return Result{}, err
148 }
149 if strings.TrimSpace(text) == "" {
150 return Result{}, errors.New("empty summary")
151 }
152
153 questions := len(old)
154 if merge {
155 questions-- // the summary pair is not a question the user asked
156 }
157 pair := SummaryPair(text, len(oldMsgs), questions, commands(oldMsgs))
158
159 out := make([]*ai.Message, 0, len(head)+len(pair)+len(msgs))
160 out = append(out, head...)
161 out = append(out, pair...)
162 kept := flatten(recent)
163 out = append(out, kept...)
164
165 // The safety net: the cut above cannot split a pair, but the check is
166 // cheap and the failure it guards against is a 400 on the next question.
167 if err := Valid(out); err != nil {
168 return Result{}, fmt.Errorf("compressed history is invalid: %w", err)
169 }
170
171 return Result{
172 Messages: out,
173 Compressed: len(oldMsgs),
174 Kept: len(kept),
175 Before: Estimate(msgs),
176 After: Estimate(out),
177 Elapsed: time.Since(start),
178 }, nil
179}
180
181// Split separates the leading system message(s) from the question turns. A
182// turn starts at a user message and runs until the next one, so it holds the
183// whole model ↔ tools exchange the question caused; an earlier summary pair is
184// a turn of its own, since it starts with a user message too.
185//
186// Every strategy of CONTEXT_WINDOW.md cuts on these boundaries: cutting on a
187// message count can leave a `tool` message without the `assistant` that asked
188// for it, which the OpenAI API refuses outright.
189func Split(msgs []*ai.Message) (head []*ai.Message, turns [][]*ai.Message) {
190 i := 0
191 for i < len(msgs) && msgs[i].Role == ai.RoleSystem {
192 i++
193 }
194 head = msgs[:i]
195 for _, m := range msgs[i:] {
196 if m.Role == ai.RoleUser || len(turns) == 0 {
197 turns = append(turns, nil)
198 }
199 turns[len(turns)-1] = append(turns[len(turns)-1], m)
200 }
201 return head, turns
202}
203
204// Valid checks what an OpenAI-compatible server checks before answering: the
205// history starts with the system prompt, every tool request sits in a model
206// message, and every tool response answers a request seen earlier — paired by
207// Ref when the server set one, by tool name otherwise (same rule as
208// engine.executed).
209func Valid(msgs []*ai.Message) error {
210 if len(msgs) == 0 || msgs[0].Role != ai.RoleSystem {
211 return errors.New("history does not start with the system prompt")
212 }
213 type call struct{ ref, name string }
214 var pending []call
215 for i, m := range msgs {
216 for _, p := range m.Content {
217 switch {
218 case p.IsToolRequest() && p.ToolRequest != nil:
219 if m.Role != ai.RoleModel {
220 return fmt.Errorf("message %d: tool request in a %s message", i, m.Role)
221 }
222 pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name})
223 case p.IsToolResponse() && p.ToolResponse != nil:
224 r := p.ToolResponse
225 j := slices.IndexFunc(pending, func(c call) bool {
226 if r.Ref != "" {
227 return c.ref == r.Ref
228 }
229 return c.name == r.Name
230 })
231 if j < 0 {
232 return fmt.Errorf("message %d: tool response %q answers no pending request", i, r.Name)
233 }
234 pending = slices.Delete(pending, j, j+1)
235 }
236 }
237 }
238 return nil
239}
240
241// Estimate approximates the token count of a history without asking the
242// engine: characters divided by 3.5, plus a few tokens of framing per message.
243//
244// 4 characters per token holds for English prose; code, JSON, paths and French
245// tokenise closer to 3. Taking 3.5 over-estimates prose a little, which is the
246// safe side for a threshold. The engine's own count, when it reports one, is
247// preferred by Decide — this is the fallback, and the number the report shows
248// for a history that was never sent.
249func Estimate(msgs []*ai.Message) int {
250 chars := 0
251 for _, m := range msgs {
252 for _, p := range m.Content {
253 switch {
254 case p.IsText():
255 chars += len(p.Text)
256 case p.IsToolRequest() && p.ToolRequest != nil:
257 chars += len(p.ToolRequest.Name) + len(jsonOf(p.ToolRequest.Input))
258 case p.IsToolResponse() && p.ToolResponse != nil:
259 chars += len(p.ToolResponse.Name) + len(jsonOf(p.ToolResponse.Output))
260 }
261 }
262 }
263 return (chars*2+6)/7 + 4*len(msgs)
264}
265
266// jsonOf renders a tool payload the way the request will carry it. A string
267// output is taken as-is: marshalling it would count the escaping twice.
268func jsonOf(v any) string {
269 if s, ok := v.(string); ok {
270 return s
271 }
272 b, err := json.Marshal(v)
273 if err != nil {
274 return fmt.Sprint(v)
275 }
276 return string(b)
277}
278
279// IsSummary reports whether a message is the notes half of a summary pair.
280func IsSummary(m *ai.Message) bool {
281 return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary"
282}
283
284// flatten joins turns back into one message list.
285func flatten(turns [][]*ai.Message) []*ai.Message {
286 var out []*ai.Message
287 for _, t := range turns {
288 out = append(out, t...)
289 }
290 return out
291}
292
293// commands counts the bash commands that actually ran in a history — one per
294// response of the `bash` tool, the same measure as engine.Commands. It is not
295// imported from engine so that this package stays free of Genkit's wiring and
296// testable with a plain function.
297func commands(msgs []*ai.Message) int {
298 n := 0
299 for _, m := range msgs {
300 for _, p := range m.Content {
301 if p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == "bash" {
302 n++
303 }
304 }
305 }
306 return n
307}