Skip to content

Commit f5495a2

Browse files
committed
Correctness of jsonArrayTokenSizer**: The byte formula `2 + (N-1) +
1 parent ee842c6 commit f5495a2

2 files changed

Lines changed: 171 additions & 10 deletions

File tree

internal/orchestrator/replan_prompt_budgeter.go

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,15 @@ func (b ReplanPromptBudgeter) compactRecentResults(results []WorkerTurnResult, b
167167
}
168168
recent = append(recent, b.compactWorkerResult(result))
169169
}
170-
for approxJSONTokens(recent) > b.RecentResultsTokens {
170+
recentTokens := newJSONArrayTokenSizer(recent)
171+
for recentTokens.tokens() > b.RecentResultsTokens {
171172
dropped := false
172173
for i, result := range recent {
173174
if isHighPriorityPromptResult(result, blocked) {
174175
continue
175176
}
176177
recent = append(recent[:i], recent[i+1:]...)
178+
recentTokens.drop(i)
177179
dropped = true
178180
break
179181
}
@@ -216,7 +218,8 @@ func (b ReplanPromptBudgeter) compactWorkerResult(result WorkerTurnResult) Worke
216218

217219
func (b ReplanPromptBudgeter) compactContextLedger(entries []ContextLedgerEntry) []ContextLedgerEntry {
218220
compact := compactContextLedgerForPrompt(entries)
219-
for approxJSONTokens(compact) > b.ContextLedgerTokens && len(compact) > 0 {
221+
compactTokens := newJSONArrayTokenSizer(compact)
222+
for compactTokens.tokens() > b.ContextLedgerTokens && len(compact) > 0 {
220223
dropIndex := -1
221224
for i, entry := range compact {
222225
if entry.Error == "" && !strings.Contains(entry.Kind, "candidate") {
@@ -228,6 +231,7 @@ func (b ReplanPromptBudgeter) compactContextLedger(entries []ContextLedgerEntry)
228231
dropIndex = 0
229232
}
230233
compact = append(compact[:dropIndex], compact[dropIndex+1:]...)
234+
compactTokens.drop(dropIndex)
231235
}
232236
return compact
233237
}
@@ -237,10 +241,13 @@ func (b ReplanPromptBudgeter) compactPullRequestFeedback(items []PullRequestFeed
237241
for i := range compact {
238242
compact[i].Prompt = truncateStringForPrompt(compact[i].Prompt, tokensToApproxChars(1000))
239243
}
240-
for approxJSONTokens(compact) > b.PullRequestFeedbackTokens && len(compact) > 0 {
244+
compactTokens := newJSONArrayTokenSizer(compact)
245+
for compactTokens.tokens() > b.PullRequestFeedbackTokens && len(compact) > 0 {
241246
compact[0].Prompt = truncateStringForPrompt(compact[0].Prompt, tokensToApproxChars(250))
242-
if approxJSONTokens(compact) > b.PullRequestFeedbackTokens {
247+
compactTokens.update(0, compact[0])
248+
if compactTokens.tokens() > b.PullRequestFeedbackTokens {
243249
compact = compact[1:]
250+
compactTokens.drop(0)
244251
}
245252
}
246253
return compact
@@ -259,7 +266,8 @@ func (b ReplanPromptBudgeter) compactArtifacts(artifacts []core.TaskArtifact) []
259266
}
260267
compact = append(compact, item)
261268
}
262-
for approxJSONTokens(compact) > b.ArtifactsTokens && len(compact) > 0 {
269+
compactTokens := newJSONArrayTokenSizer(compact)
270+
for compactTokens.tokens() > b.ArtifactsTokens && len(compact) > 0 {
263271
dropIndex := 0
264272
for i, artifact := range compact {
265273
if artifact.Kind != "worker_result_digest" {
@@ -268,12 +276,14 @@ func (b ReplanPromptBudgeter) compactArtifacts(artifacts []core.TaskArtifact) []
268276
}
269277
}
270278
compact = append(compact[:dropIndex], compact[dropIndex+1:]...)
279+
compactTokens.drop(dropIndex)
271280
}
272281
return compact
273282
}
274283

275284
func (b ReplanPromptBudgeter) degradeToTotalBudget(payload map[string]any, state ReplanPromptState) ReplanPromptState {
276-
for approxJSONTokens(payload) > b.TotalTokens {
285+
currentTokens := approxJSONTokens(payload)
286+
for currentTokens > b.TotalTokens {
277287
switch {
278288
case len(state.Artifacts) > 0:
279289
state.Artifacts = state.Artifacts[1:]
@@ -288,16 +298,18 @@ func (b ReplanPromptBudgeter) degradeToTotalBudget(payload map[string]any, state
288298
state.RecentResults[i].Changes.ChangedFiles = nil
289299
}
290300
state.RecoveryHint = truncateStringForPrompt(state.RecoveryHint, tokensToApproxChars(250))
301+
payload["state"] = state
291302
state.PromptBudget.ApproxTokens = approxJSONTokens(payload)
292303
return state
293304
}
294-
state.PromptBudget.ApproxTokens = approxJSONTokens(state)
295305
state.PromptBudget.RecentResultCount = len(state.RecentResults)
296306
state.PromptBudget.ArtifactCount = len(state.Artifacts)
297307
state.PromptBudget.ContextLedgerCount = len(state.ContextLedger)
298308
payload["state"] = state
309+
currentTokens = approxJSONTokens(payload)
310+
state.PromptBudget.ApproxTokens = currentTokens
299311
}
300-
state.PromptBudget.ApproxTokens = approxJSONTokens(payload)
312+
state.PromptBudget.ApproxTokens = currentTokens
301313
return state
302314
}
303315

@@ -317,8 +329,10 @@ func compactTaskSteeringForPrompt(items []string) []string {
317329
for i := range compact {
318330
compact[i] = truncateStringForPrompt(compact[i], tokensToApproxChars(1000))
319331
}
320-
for approxJSONTokens(compact) > 4000 && len(compact) > 1 {
332+
compactTokens := newJSONArrayTokenSizer(compact)
333+
for compactTokens.tokens() > 4000 && len(compact) > 1 {
321334
compact = compact[1:]
335+
compactTokens.drop(0)
322336
}
323337
return compact
324338
}
@@ -404,11 +418,76 @@ func approxJSONTokens(value any) int {
404418
return approxTokensForString(string(data))
405419
}
406420

421+
type jsonArrayTokenSizer[T any] struct {
422+
nilSlice bool
423+
itemBytes []int
424+
}
425+
426+
func newJSONArrayTokenSizer[T any](items []T) *jsonArrayTokenSizer[T] {
427+
sizer := &jsonArrayTokenSizer[T]{
428+
nilSlice: items == nil,
429+
itemBytes: make([]int, 0, len(items)),
430+
}
431+
for _, item := range items {
432+
sizer.itemBytes = append(sizer.itemBytes, jsonEncodedBytes(item))
433+
}
434+
return sizer
435+
}
436+
437+
func (s *jsonArrayTokenSizer[T]) tokens() int {
438+
return approxTokensForBytes(s.bytes())
439+
}
440+
441+
func (s *jsonArrayTokenSizer[T]) bytes() int {
442+
if s == nil || s.nilSlice {
443+
return len("null")
444+
}
445+
if len(s.itemBytes) == 0 {
446+
return len("[]")
447+
}
448+
total := len("[]") + len(s.itemBytes) - 1
449+
for _, bytes := range s.itemBytes {
450+
total += bytes
451+
}
452+
return total
453+
}
454+
455+
func (s *jsonArrayTokenSizer[T]) drop(index int) {
456+
if s == nil || index < 0 || index >= len(s.itemBytes) {
457+
return
458+
}
459+
s.nilSlice = false
460+
s.itemBytes = append(s.itemBytes[:index], s.itemBytes[index+1:]...)
461+
}
462+
463+
func (s *jsonArrayTokenSizer[T]) update(index int, item T) {
464+
if s == nil || index < 0 || index >= len(s.itemBytes) {
465+
return
466+
}
467+
s.nilSlice = false
468+
s.itemBytes[index] = jsonEncodedBytes(item)
469+
}
470+
471+
func jsonEncodedBytes(value any) int {
472+
data, err := json.Marshal(value)
473+
if err != nil {
474+
return 0
475+
}
476+
return len(data)
477+
}
478+
479+
func approxTokensForBytes(bytes int) int {
480+
if bytes <= 0 {
481+
return 0
482+
}
483+
return (bytes + 3) / 4
484+
}
485+
407486
func approxTokensForString(value string) int {
408487
if value == "" {
409488
return 0
410489
}
411-
return (len(value) + 3) / 4
490+
return approxTokensForBytes(len(value))
412491
}
413492

414493
func tokensToApproxChars(tokens int) int {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package orchestrator
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestJSONArrayTokenSizerMatchesMarshalEstimate(t *testing.T) {
10+
var nilItems []string
11+
if got, want := newJSONArrayTokenSizer(nilItems).tokens(), approxJSONTokens(nilItems); got != want {
12+
t.Fatalf("nil slice tokens = %d, want %d", got, want)
13+
}
14+
15+
items := []string{"alpha", strings.Repeat("beta", 20), "gamma"}
16+
sizer := newJSONArrayTokenSizer(items)
17+
if got, want := sizer.tokens(), approxJSONTokens(items); got != want {
18+
t.Fatalf("initial tokens = %d, want %d", got, want)
19+
}
20+
21+
items = append(items[:1], items[2:]...)
22+
sizer.drop(1)
23+
if got, want := sizer.tokens(), approxJSONTokens(items); got != want {
24+
t.Fatalf("tokens after drop = %d, want %d", got, want)
25+
}
26+
27+
items[0] = strings.Repeat("delta", 30)
28+
sizer.update(0, items[0])
29+
if got, want := sizer.tokens(), approxJSONTokens(items); got != want {
30+
t.Fatalf("tokens after update = %d, want %d", got, want)
31+
}
32+
33+
items = items[:0]
34+
sizer.drop(0)
35+
sizer.drop(0)
36+
if got, want := sizer.tokens(), approxJSONTokens(items); got != want {
37+
t.Fatalf("empty slice tokens = %d, want %d", got, want)
38+
}
39+
}
40+
41+
func TestCompactContextLedgerUsesBudget(t *testing.T) {
42+
budgeter := DefaultReplanPromptBudgeter()
43+
budgeter.ContextLedgerTokens = 600
44+
entries := largeContextLedgerEntries(24)
45+
46+
compact := budgeter.compactContextLedger(entries)
47+
if got := approxJSONTokens(compact); got > budgeter.ContextLedgerTokens {
48+
t.Fatalf("compact context ledger tokens = %d, want <= %d", got, budgeter.ContextLedgerTokens)
49+
}
50+
if len(compact) >= len(entries) {
51+
t.Fatalf("compact context ledger count = %d, want fewer than %d", len(compact), len(entries))
52+
}
53+
}
54+
55+
func BenchmarkCompactContextLedgerLarge(b *testing.B) {
56+
budgeter := DefaultReplanPromptBudgeter()
57+
budgeter.ContextLedgerTokens = 6000
58+
entries := largeContextLedgerEntries(240)
59+
60+
b.ReportAllocs()
61+
for i := 0; i < b.N; i++ {
62+
compact := budgeter.compactContextLedger(entries)
63+
if len(compact) == 0 {
64+
b.Fatal("empty compact ledger")
65+
}
66+
}
67+
}
68+
69+
func largeContextLedgerEntries(count int) []ContextLedgerEntry {
70+
entries := make([]ContextLedgerEntry, 0, count)
71+
for i := 0; i < count; i++ {
72+
entries = append(entries, ContextLedgerEntry{
73+
Kind: "task_action",
74+
Status: "rejected",
75+
Summary: fmt.Sprintf("context fact %03d: %s", i, strings.Repeat("large prompt detail ", 80)),
76+
Metadata: map[string]any{
77+
"turn": i,
78+
},
79+
})
80+
}
81+
return entries
82+
}

0 commit comments

Comments
 (0)