Skip to content

Commit 4c7d2b2

Browse files
authored
fix(truncate): pick a stable record path when arrays tie (#1770)
## Bug When a tool response is over the truncation limit, `findArraysRecursive` (`internal/truncate/truncator.go`) collects candidate record arrays by ranging over each JSON object, and `chooseBestArray` picks one. On a tie, `chooseBestArray` keeps whichever candidate came first. Go randomizes map iteration, so identical payloads with equally good sibling arrays get a different `RecordPath` on different calls. The `read_cache` handle minted for that response then pages a different array each time. ## Trigger An upstream tool returns, for example, `{"zeta":[…20 rows], "alpha":[…20 rows], "mid":[…20 rows]}` and the response is over the limit. `content_forward.go` calls `Truncate` on every such response. Repeated identical calls return `record_path` values of `zeta`, `alpha`, or `mid` depending on map order. ## Fix Object keys are now traversed in sorted order, so the tie-break is stable: the first key in sorted order wins. Non-tied selection is unchanged. ## Verification - `go test -count=1 -run TestTruncate_TiedArraysPickStableRecordPath ./internal/truncate/` passes. The test runs 50 calls on the payload above and expects `alpha` every time. - With `truncator.go` reverted (`git stash push internal/truncate/truncator.go`), the same test fails: `call 0: record path "zeta", want "alpha"`. - `go test -count=1 ./internal/truncate/` passes. - `go vet ./internal/truncate/` is clean. - `gofmt -l internal/truncate/` is clean. - I also ran `go test -count=1 ./internal/server/` locally, but it hit the default 10-minute test timeout, which isn't a result either way. A narrower rerun failed at setup because of a local build-cache error. I'm relying on CI for that package. Commands were run with `GOFLAGS=-mod=readonly CGO_ENABLED=0`.
1 parent 464dea4 commit 4c7d2b2

2 files changed

Lines changed: 42 additions & 2 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package truncate
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// Two equally good record arrays must resolve to the same record path on
10+
// every call, or identical upstream responses get cache handles paging
11+
// different arrays.
12+
func TestTruncate_TiedArraysPickStableRecordPath(t *testing.T) {
13+
row := func(i int) string { return fmt.Sprintf(`{"id":%d,"name":"%s"}`, i, strings.Repeat("x", 40)) }
14+
rows := make([]string, 20)
15+
for i := range rows {
16+
rows[i] = row(i)
17+
}
18+
arr := "[" + strings.Join(rows, ",") + "]"
19+
content := fmt.Sprintf(`{"zeta":%s,"alpha":%s,"mid":%s}`, arr, arr, arr)
20+
21+
tr := NewTruncator(500)
22+
for i := 0; i < 50; i++ {
23+
res := tr.Truncate(content, "tool", nil)
24+
if !res.CacheAvailable {
25+
t.Fatalf("expected a cache handle, got simple truncation")
26+
}
27+
if res.RecordPath != "alpha" {
28+
t.Fatalf("call %d: record path %q, want %q", i, res.RecordPath, "alpha")
29+
}
30+
}
31+
}

‎internal/truncate/truncator.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package truncate
33
import (
44
"encoding/json"
55
"fmt"
6+
"sort"
67
"strings"
78
"unicode/utf8"
89

@@ -137,8 +138,16 @@ func (t *Truncator) findArraysRecursive(data interface{}, currentPath string, de
137138
}
138139

139140
case map[string]interface{}:
140-
// Traverse object fields
141-
for key, val := range v {
141+
// Traverse object fields in sorted order: chooseBestArray keeps the
142+
// first of equally good arrays, so map order would otherwise pick a
143+
// different record path for the same payload on each call.
144+
keys := make([]string, 0, len(v))
145+
for key := range v {
146+
keys = append(keys, key)
147+
}
148+
sort.Strings(keys)
149+
for _, key := range keys {
150+
val := v[key]
142151
newPath := key
143152
if currentPath != "" {
144153
newPath = currentPath + "." + key

0 commit comments

Comments
 (0)