From: Anderson A. Giacomolli Date: Fri, 6 Nov 2026 19:55:18 +0000 (-0300) Subject: Send the compaction view in blocks of four lines X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/56129a5fcbcbe51a7ac7d6b009d4551ed9f71109?ds=sidebyside Send the compaction view in blocks of four lines 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 --- diff --git a/compactor.go b/compactor.go index 16303b0..fe8a00f 100644 --- a/compactor.go +++ b/compactor.go @@ -4,12 +4,18 @@ import ( "context" "errors" "fmt" + "slices" "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. @@ -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 -// 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 @@ -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) { - view := task.Context + "\n" + view := viewParts(task.Context) 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 { - parts := []string{view, text} + parts := slices.Concat(view, []string{text}) 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 } +// 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, "\n"), "\n") + whole := len(lines) - 1 - (len(lines)-1)%viewBlock + parts := []string{"\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 { diff --git a/compactor_test.go b/compactor_test.go index 35e09eb..cc99f33 100644 --- a/compactor_test.go +++ b/compactor_test.go @@ -20,7 +20,7 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) { line, err := compactor.Summarize(t.Context(), Task{ Level: 0, Index: 7, - Context: "\n0+4|user: earlier\n", + Context: "\n0+4|a\n4+1|b\n5+1|c\n6+1|d\n7+1|e\n", 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{ - "\n0+4|user: earlier\n\n", + "\n", + "0+4|a\n4+1|b\n5+1|c\n6+1|d\n", + "7+1|e\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" + @@ -50,6 +52,55 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) { }, parts) } +func TestViewPartsSendWholeBlocksOfLines(t *testing.T) { + testCases := []struct { + name string + context string + want []string + }{ + { + name: "empty view has the tags only", + context: "\n", + want: []string{"\n", "\n"}, + }, + { + name: "lines short of a block go with the closing tag", + context: "\n0+1|a\n1+1|b\n2+1|c\n", + want: []string{"\n", "0+1|a\n1+1|b\n2+1|c\n\n"}, + }, + { + name: "one whole block", + context: "\n0+1|a\n1+1|b\n2+1|c\n3+1|d\n", + want: []string{"\n", "0+1|a\n1+1|b\n2+1|c\n3+1|d\n", "\n"}, + }, + { + name: "two whole blocks and a rest", + context: "\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", + want: []string{ + "\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\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("\n0+1|a\n1+1|b\n2+1|c\n3+1|d\n4+1|e\n") + longer := viewParts("\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") + 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, @@ -86,18 +137,18 @@ func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) { } 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) - 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", - "text": parts[2], + "text": parts[3], }) require.NoError(t, err) require.LessOrEqual(t, len(call.raw), len(server.Call(0).raw)+len(",")+len(note))