]> git.giacomolli.uk - onechat/commitdiff
Send the compaction view in blocks of four lines
authorAnderson A. Giacomolli <redacted>
Fri, 6 Nov 2026 19:55:18 +0000 (16:55 -0300)
committerAnderson A. Giacomolli <redacted>
Fri, 6 Nov 2026 19:55:18 +0000 (16:55 -0300)
A compaction sends its view as separate input parts of the user
message: the opening tag, one part for each whole block of four lines,
and the rest of the lines with the closing tag. The task and the note
of a retry follow as parts of their own. Joined, the parts are the same
bytes as the single view part before, so the model reads the same text.

The views of two compactions end at different lines when builds run at
once, so before this change no two first tries sent the same view part.
With blocks, they send the same parts up to the last whole block that
both views have. This tests the hypothesis that the ChatGPT backend
reuses a cache entry only up to the end of an input part that both
requests have. The explicit cache breakpoints of the reference
implementation are not used, because the ChatGPT endpoint rejects both
prompt_cache_breakpoint and prompt_cache_options with gpt-6.1-sol.

Issue: 8c5fd2a

compactor.go
compactor_test.go

index 16303b0c342cc7a45784754076501e53344918e2..fe8a00ffd8c3df5038117400cf0b8de6f7b9a437 100644 (file)
@@ -4,12 +4,18 @@ import (
        "context"
        "errors"
        "fmt"
        "context"
        "errors"
        "fmt"
+       "slices"
        "strings"
        "unicode/utf8"
 )
 
        "strings"
        "unicode/utf8"
 )
 
-// compactionTries is the most calls for one node.
-const compactionTries = 5
+const (
+       // compactionTries is the most calls for one node.
+       compactionTries = 5
+
+       // viewBlock is the number of view lines in one input part.
+       viewBlock = 4
+)
 
 // 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.
 
 // 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.
@@ -18,7 +24,9 @@ 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
 // 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.
+// user message with the compaction view up to the node and the task. The view
+// goes in parts of viewBlock lines, as viewParts gives them, so compactions
+// whose views end at different lines still send the same first parts.
 //
 // 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
 //
 // 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
@@ -47,7 +55,7 @@ func newCompactor(client *Client, name string, model ModelConfig) *Compactor {
 
 // Summarize returns the line of the node of task.
 func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
 
 // Summarize returns the line of the node of task.
 func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
-       view := task.Context + "\n"
+       view := viewParts(task.Context)
        text, err := compactionTask(task)
        if err != nil {
                return "", err
        text, err := compactionTask(task)
        if err != nil {
                return "", err
@@ -56,7 +64,7 @@ func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
        var lines []string
        note := ""
        for len(lines) < compactionTries {
        var lines []string
        note := ""
        for len(lines) < compactionTries {
-               parts := []string{view, text}
+               parts := slices.Concat(view, []string{text})
                if note != "" {
                        parts = append(parts, note)
                }
                if note != "" {
                        parts = append(parts, note)
                }
@@ -95,6 +103,21 @@ func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
        return shortest(lines), nil
 }
 
        return shortest(lines), nil
 }
 
+// viewParts returns the parts of the compaction view context: the opening tag,
+// one part for each whole block of viewBlock lines, and the rest of the lines
+// with the closing tag and a newline. Joined, the parts are context and a
+// newline.
+func viewParts(context string) []string {
+       lines := strings.SplitAfter(strings.TrimPrefix(context, "<chat>\n"), "\n")
+       whole := len(lines) - 1 - (len(lines)-1)%viewBlock
+       parts := []string{"<chat>\n"}
+       for n := 0; n < whole; n += viewBlock {
+               parts = append(parts, strings.Join(lines[n:n+viewBlock], ""))
+       }
+
+       return append(parts, strings.Join(lines[whole:], "")+"\n")
+}
+
 // compactionTask returns the task of a build, with its input.
 func compactionTask(task Task) (string, error) {
        if task.Level == 0 && len(task.Input) == 1 {
 // compactionTask returns the task of a build, with its input.
 func compactionTask(task Task) (string, error) {
        if task.Level == 0 && len(task.Input) == 1 {
index 35e09eb28b4562530af8dae474fa13d62b49ad8f..cc99f330720ec5049895161db5af8fb627c207fe 100644 (file)
@@ -20,7 +20,7 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) {
        line, err := compactor.Summarize(t.Context(), Task{
                Level:   0,
                Index:   7,
        line, err := compactor.Summarize(t.Context(), Task{
                Level:   0,
                Index:   7,
-               Context: "<chat>\n0+4|user: earlier\n</chat>",
+               Context: "<chat>\n0+4|a\n4+1|b\n5+1|c\n6+1|d\n7+1|e\n</chat>",
                Input:   []string{"user: " + strings.Repeat("x", 600)},
        })
        require.NoError(t, err)
                Input:   []string{"user: " + strings.Repeat("x", 600)},
        })
        require.NoError(t, err)
@@ -42,7 +42,9 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) {
 
        parts := inputParts(t, server.Call(0))
        require.Equal(t, []string{
 
        parts := inputParts(t, server.Call(0))
        require.Equal(t, []string{
-               "<chat>\n0+4|user: earlier\n</chat>\n",
+               "<chat>\n",
+               "0+4|a\n4+1|b\n5+1|c\n6+1|d\n",
+               "7+1|e\n</chat>\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" +
                "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" +
@@ -50,6 +52,55 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) {
        }, parts)
 }
 
        }, parts)
 }
 
+func TestViewPartsSendWholeBlocksOfLines(t *testing.T) {
+       testCases := []struct {
+               name    string
+               context string
+               want    []string
+       }{
+               {
+                       name:    "empty view has the tags only",
+                       context: "<chat>\n</chat>",
+                       want:    []string{"<chat>\n", "</chat>\n"},
+               },
+               {
+                       name:    "lines short of a block go with the closing tag",
+                       context: "<chat>\n0+1|a\n1+1|b\n2+1|c\n</chat>",
+                       want:    []string{"<chat>\n", "0+1|a\n1+1|b\n2+1|c\n</chat>\n"},
+               },
+               {
+                       name:    "one whole block",
+                       context: "<chat>\n0+1|a\n1+1|b\n2+1|c\n3+1|d\n</chat>",
+                       want:    []string{"<chat>\n", "0+1|a\n1+1|b\n2+1|c\n3+1|d\n", "</chat>\n"},
+               },
+               {
+                       name:    "two whole blocks and a rest",
+                       context: "<chat>\n0+2|a\n2+1|b\n3+1|c\n4+1|d\n5+1|e\n6+1|f\n7+1|g\n8+1|h\n9+1|i\n</chat>",
+                       want: []string{
+                               "<chat>\n",
+                               "0+2|a\n2+1|b\n3+1|c\n4+1|d\n",
+                               "5+1|e\n6+1|f\n7+1|g\n8+1|h\n",
+                               "9+1|i\n</chat>\n",
+                       },
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       parts := viewParts(tc.context)
+                       require.Equal(t, tc.want, parts)
+                       require.Equal(t, tc.context+"\n", strings.Join(parts, ""))
+               })
+       }
+}
+
+func TestViewPartsKeepTheBlocksOfAShorterView(t *testing.T) {
+       shorter := viewParts("<chat>\n0+1|a\n1+1|b\n2+1|c\n3+1|d\n4+1|e\n</chat>")
+       longer := viewParts("<chat>\n0+1|a\n1+1|b\n2+1|c\n3+1|d\n4+1|e\n5+1|f\n6+1|g\n7+1|h\n</chat>")
+       require.Equal(t, shorter[:2], longer[:2])
+       require.NotEqual(t, shorter[2], longer[2])
+}
+
 func TestCompactorWritesTheMergeTask(t *testing.T) {
        text, err := compactionTask(Task{
                Level: 2,
 func TestCompactorWritesTheMergeTask(t *testing.T) {
        text, err := compactionTask(Task{
                Level: 2,
@@ -86,18 +137,18 @@ func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) {
        }
 
        first := inputParts(t, server.Call(0))
        }
 
        first := inputParts(t, server.Call(0))
-       require.Len(t, first, 2)
+       require.Len(t, first, 3)
        for n, want := range []string{long[0], long[1]} {
                call := server.Call(n + 1)
                parts := inputParts(t, call)
        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")
+               require.Equal(t, first, parts[:3])
+               require.Equal(t, []string{tooLong(want)}, parts[3:])
+               require.Contains(t, parts[3], "was "+strconv.Itoa(len(want))+" bytes,")
+               require.Contains(t, parts[3], "\n"+want[:512]+"| <- LIMIT")
 
                note, err := json.Marshal(map[string]string{
                        "type": "input_text",
 
                note, err := json.Marshal(map[string]string{
                        "type": "input_text",
-                       "text": parts[2],
+                       "text": parts[3],
                })
                require.NoError(t, err)
                require.LessOrEqual(t, len(call.raw), len(server.Call(0).raw)+len(",")+len(note))
                })
                require.NoError(t, err)
                require.LessOrEqual(t, len(call.raw), len(server.Call(0).raw)+len(",")+len(note))