From 30fbeb13a74942c6d515cc2a51eab7407a78c325 Mon Sep 17 00:00:00 2001 From: "Anderson A. Giacomolli" Date: Fri, 6 Nov 2026 14:56:02 -0300 Subject: [PATCH] Build the summary nodes with a model The compactor is the Summarizer of the memory core. Each build is a call like a turn, with the same system prompt, tools, and cache key, so it reads the prompt cache of the turns and never calls a tool. The input is one user message with the compaction view up to the node and the task: the leaf or merge text of the reference design with the 512-dash ruler. A line over 512 bytes gets another try with the same view and task plus only the newest note, which gives its size and its cut at the limit. The model's earlier reply and reasoning never go back, so each try has the same cached prefix and about the same size. After at most five tries the shortest line is the node. A call that fails before any line fails the build, and the core tries it again at the next message. The model and effort come from the compactor settings of config.json. Issue: b14d537 --- compactor.go | 160 +++++++++++++++++++++++++++++++ compactor_test.go | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 compactor.go create mode 100644 compactor_test.go diff --git a/compactor.go b/compactor.go new file mode 100644 index 0000000..6840fd4 --- /dev/null +++ b/compactor.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// compactionTries is the most calls for one node. +const compactionTries = 5 + +// ruler shows the model the size limit of a line. Models cannot count bytes, +// and a sample line as the ruler gets its content copied. +var ruler = strings.Repeat("-", nodeBytes) + +// Compactor builds the nodes of one agent with a model. A compaction is a call +// like a turn, with the same system prompt and tools, so it reads them from the +// prompt cache of the turns. The tools are never called. After them comes one +// user message with the compaction view up to the node and the task. +// +// When the line is over nodeBytes, the next try sends the same view and task, +// plus a note with the size of the newest line and its cut at the limit. A try +// never sends the model's earlier reply or reasoning back, so every try has the +// same cached prefix and about the same size. After at most compactionTries +// tries, the shortest line is the node. A few bytes over the limit is fine, +// because the view measures the real sizes. A call that fails before any line +// fails the build, and the core tries it again at the next message. +type Compactor struct { + client *Client + model ModelConfig + instructions string + tools []Tool +} + +// newCompactor returns the compactor of the agent name, which sends its calls +// through client with model. +func newCompactor(client *Client, name string, model ModelConfig) *Compactor { + return &Compactor{ + client: client, + model: model, + instructions: prompt(name), + tools: agentTools(), + } +} + +// Summarize returns the line of the node of task. +func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) { + view := task.Context + "\n" + text, err := compactionTask(task) + if err != nil { + return "", err + } + + var lines []string + note := "" + for len(lines) < compactionTries { + parts := []string{view, text} + if note != "" { + parts = append(parts, note) + } + + var response Response + response, err = c.client.Create(ctx, Request{ + Model: c.model.Model, + Effort: c.model.Effort, + Instructions: c.instructions, + Tools: c.tools, + Input: []any{userMessage(parts...)}, + }) + if err != nil { + break + } + + line := strings.TrimSpace(response.Text) + if line == "" { + err = errors.New("compaction gave no line") + break + } + + lines = append(lines, line) + if len(line) <= nodeBytes { + break + } + + note = tooLong(line) + } + + if len(lines) == 0 { + return "", fmt.Errorf("compact node (%d, %d): %w", task.Level, task.Index, err) + } + + return shortest(lines), nil +} + +// compactionTask returns the task of a build, with its input. +func compactionTask(task Task) (string, error) { + if task.Level == 0 && len(task.Input) == 1 { + return fmt.Sprintf(`Compaction: compress message %d into one line of at most %d bytes +(about 70 words), the length of this ruler: +%s + +%s +`, task.Index, nodeBytes, ruler, task.Input[0]), nil + } + + if task.Level > 0 && len(task.Input) == 2 { + first := task.Index << task.Level + half := 1 << (task.Level - 1) + + return fmt.Sprintf(`Compaction: merge lines %d+%d and %d+%d, adjacent, into one line of at most +%d bytes (about 70 words), the length of this ruler: +%s + may hold their messages, %d to %d, in more detail: take details +of them from there too. + +%s +%s +`, first, half, first+half, half, nodeBytes, ruler, first, nodeEnd(task.Level, task.Index)-1, task.Input[0], task.Input[1]), nil + } + + return "", fmt.Errorf("node (%d, %d) has %d inputs", task.Level, task.Index, len(task.Input)) +} + +// tooLong returns the note for a line over nodeBytes: its size and its first +// nodeBytes bytes, which show the model where the limit cuts it. +func tooLong(line string) string { + return fmt.Sprintf(`Too long: your last line for this was %d bytes, +over the %d-byte limit. Write the whole line again, cutting just +enough of the least valuable items to fit before this cut: +%s| ← LIMIT`, len(line), nodeBytes, cutLine(line)) +} + +// cutLine returns the first nodeBytes bytes of line, without a part of a +// character at the end. +func cutLine(line string) string { + if len(line) <= nodeBytes { + return line + } + + cut := line[:nodeBytes] + for !utf8.ValidString(cut) { + cut = cut[:len(cut)-1] + } + + return cut +} + +// shortest returns the first of the shortest lines. +func shortest(lines []string) string { + best := lines[0] + for _, line := range lines[1:] { + if len(line) < len(best) { + best = line + } + } + + return best +} diff --git a/compactor_test.go b/compactor_test.go new file mode 100644 index 0000000..7813c77 --- /dev/null +++ b/compactor_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/require" +) + +func TestCompactorSendsThePromptToolsAndTask(t *testing.T) { + server := newFakeResponses(t) + compactor := newTestCompactor(t, server) + + line, err := compactor.Summarize(t.Context(), Task{ + Level: 0, + Index: 7, + Context: "\n0+4|user: earlier\n", + Input: []string{"user: " + strings.Repeat("x", 600)}, + }) + require.NoError(t, err) + require.Equal(t, "a line", line) + require.Equal(t, 1, server.Calls()) + + body := server.Call(0).body + require.Equal(t, prompt("Ada"), body["instructions"]) + require.Equal(t, "gpt-test", body["model"]) + require.Equal(t, map[string]any{"effort": "high"}, body["reasoning"]) + require.Equal(t, "agent-key", body["prompt_cache_key"]) + + tools, err := json.Marshal(agentTools()) + require.NoError(t, err) + + sent, err := json.Marshal(body["tools"]) + require.NoError(t, err) + require.JSONEq(t, string(tools), string(sent)) + + parts := inputParts(t, server.Call(0)) + require.Equal(t, []string{ + "\n0+4|user: earlier\n\n", + "Compaction: compress message 7 into one line of at most 512 bytes\n" + + "(about 70 words), the length of this ruler:\n" + + strings.Repeat("-", 512) + "\n" + + "\nuser: " + strings.Repeat("x", 600) + "\n", + }, parts) +} + +func TestCompactorWritesTheMergeTask(t *testing.T) { + text, err := compactionTask(Task{ + Level: 2, + Index: 3, + Input: []string{"left line", "right line"}, + }) + require.NoError(t, err) + require.Equal(t, "Compaction: merge lines 12+2 and 14+2, adjacent, into one line of at most\n"+ + "512 bytes (about 70 words), the length of this ruler:\n"+ + strings.Repeat("-", 512)+"\n"+ + " may hold their messages, 12 to 15, in more detail: take details\n"+ + "of them from there too.\n"+ + "\nleft line\nright line\n", text) +} + +func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) { + long := []string{strings.Repeat("a", 600), strings.Repeat("b", 550), strings.Repeat("c", 400)} + server := newFakeResponses(t) + server.reply = func(w http.ResponseWriter, call fakeCall) { + writeEvents(w, completedEvent([]any{messageItem(long[call.number])}, 100, 0, 5)) + } + + line, err := newTestCompactor(t, server).Summarize(t.Context(), leafTask()) + require.NoError(t, err) + require.Equal(t, long[2], line) + require.Equal(t, 3, server.Calls()) + + first := inputParts(t, server.Call(0)) + require.Len(t, first, 2) + for n, want := range []string{long[0], long[1]} { + call := server.Call(n + 1) + parts := inputParts(t, call) + require.Equal(t, first, parts[:2]) + require.Equal(t, []string{tooLong(want)}, parts[2:]) + require.Contains(t, parts[2], "was "+strconv.Itoa(len(want))+" bytes,") + require.Contains(t, parts[2], "\n"+want[:512]+"| ← LIMIT") + + note, err := json.Marshal(map[string]string{ + "type": "input_text", + "text": parts[2], + }) + require.NoError(t, err) + require.LessOrEqual(t, len(call.raw), len(server.Call(0).raw)+len(",")+len(note)) + } +} + +func TestCompactorKeepsTheShortestOfFiveLines(t *testing.T) { + sizes := []int{700, 530, 600, 520, 560, 513} + server := newFakeResponses(t) + server.reply = func(w http.ResponseWriter, call fakeCall) { + writeEvents(w, completedEvent([]any{messageItem(strings.Repeat("z", sizes[call.number]))}, 100, 0, 5)) + } + + line, err := newTestCompactor(t, server).Summarize(t.Context(), leafTask()) + require.NoError(t, err) + require.Equal(t, compactionTries, server.Calls()) + require.Len(t, line, 520) +} + +func TestCompactorFailsWithoutALine(t *testing.T) { + testCases := []struct { + name string + reply func(http.ResponseWriter, fakeCall) + want string + }{ + { + name: "failed call", + reply: func(w http.ResponseWriter, call fakeCall) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "down") + }, + want: "HTTP 500", + }, + { + name: "tool call and no text", + reply: func(w http.ResponseWriter, call fakeCall) { + writeEvents(w, completedEvent([]any{map[string]any{"type": "function_call", "name": "zoom", "arguments": "{}"}}, 100, 0, 5)) + }, + want: "no line", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := newFakeResponses(t) + server.reply = tc.reply + + _, err := newTestCompactor(t, server).Summarize(t.Context(), leafTask()) + require.ErrorContains(t, err, tc.want) + require.Equal(t, 1, server.Calls()) + }) + } +} + +func TestCutLineKeepsWholeCharacters(t *testing.T) { + line := strings.Repeat("a", 511) + "é" + "tail" + cut := cutLine(line) + require.Equal(t, strings.Repeat("a", 511), cut) + require.True(t, utf8.ValidString(cut)) + require.Equal(t, "short", cutLine("short")) +} + +func TestCompactorBuildsNodesInTheCore(t *testing.T) { + server := newFakeResponses(t) + server.reply = func(w http.ResponseWriter, call fakeCall) { + if call.number == 0 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + writeEvents(w, completedEvent([]any{messageItem("summary of the long output")}, 100, 0, 5)) + } + + memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newTestCompactor(t, server)) + require.NoError(t, err) + + defer func() { + require.NoError(t, memory.Close()) + }() + + _, err = memory.Append("echo", strings.Repeat("output ", 200)) + require.NoError(t, err) + require.NoError(t, memory.Wait(t.Context())) + require.False(t, isBuilt(t, memory.tree, 0, 0)) + + _, err = memory.Append("user", "next") + require.NoError(t, err) + require.NoError(t, memory.Wait(t.Context())) + + node := readNode(t, memory.tree, 0, 0) + require.Equal(t, "summary of the long output", node.Text) + require.Equal(t, "\n0+1|summary of the long output\n1+1|user: next\n", memory.View()) + require.Equal(t, 2, server.Calls()) +} + +func newTestCompactor(t *testing.T, server *fakeResponses) *Compactor { + t.Helper() + + client := newClient(server.URL, apiKey(testKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) + + return newCompactor(client, "Ada", ModelConfig{ + Model: "gpt-test", + Effort: "high", + }) +} + +func leafTask() Task { + return Task{ + Level: 0, + Index: 0, + Context: "\n", + Input: []string{"user: " + strings.Repeat("y", 600)}, + } +} + +// inputParts returns the texts of the one user message of call. +func inputParts(t *testing.T, call fakeCall) []string { + t.Helper() + + var body struct { + Input []struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"input"` + } + + require.NoError(t, json.Unmarshal(call.raw, &body)) + require.Len(t, body.Input, 1) + require.Equal(t, "user", body.Input[0].Role) + + texts := make([]string, len(body.Input[0].Content)) + for n, part := range body.Input[0].Content { + require.Equal(t, "input_text", part.Type) + texts[n] = part.Text + } + + return texts +} -- 2.53.0