Skip to content

Commit 663335f

Browse files
committed
Optimize truncateFileTree token estimation and sampling seed
1 parent 59cca53 commit 663335f

2 files changed

Lines changed: 151 additions & 29 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { truncateFileTreeBasedOnTokenBudget } from '../truncate-file-tree'
3+
import type { FileTreeNode, ProjectFileContext } from '@codebuff/common/util/file'
4+
5+
const mockLogger = {
6+
debug: () => {},
7+
info: () => {},
8+
warn: () => {},
9+
error: () => {},
10+
} as any
11+
12+
describe('truncateFileTreeBasedOnTokenBudget', () => {
13+
test('returns none truncation level when within budget', () => {
14+
const fileTree: FileTreeNode[] = [
15+
{
16+
name: 'index.ts',
17+
type: 'file',
18+
filePath: 'src/index.ts',
19+
lastReadTime: 0,
20+
},
21+
{
22+
name: 'util.ts',
23+
type: 'file',
24+
filePath: 'src/util.ts',
25+
lastReadTime: 0,
26+
},
27+
]
28+
29+
const fileContext = {
30+
fileTree,
31+
fileTokenScores: {
32+
'src/index.ts': { main: 10 },
33+
},
34+
} as unknown as ProjectFileContext
35+
36+
const result = truncateFileTreeBasedOnTokenBudget({
37+
fileContext,
38+
tokenBudget: 5000,
39+
logger: mockLogger,
40+
})
41+
42+
expect(result.truncationLevel).toBe('none')
43+
expect(result.printedTree).toContain('index.ts')
44+
expect(result.printedTree).toContain('util.ts')
45+
expect(result.tokenCount).toBeGreaterThan(0)
46+
expect(result.tokenCount).toBeLessThanOrEqual(5000)
47+
})
48+
49+
test('filters out unimportant build directories and files', () => {
50+
const fileTree: FileTreeNode[] = [
51+
{
52+
name: 'src',
53+
type: 'directory',
54+
filePath: '/project/src/',
55+
children: [
56+
{
57+
name: 'main.ts',
58+
type: 'file',
59+
filePath: '/project/src/main.ts',
60+
lastReadTime: 0,
61+
},
62+
{
63+
name: 'bundle.min.js',
64+
type: 'file',
65+
filePath: '/project/src/bundle.min.js',
66+
lastReadTime: 0,
67+
},
68+
],
69+
},
70+
{
71+
name: 'dist',
72+
type: 'directory',
73+
filePath: '/project/dist/',
74+
children: [
75+
{
76+
name: 'out.js',
77+
type: 'file',
78+
filePath: '/project/dist/out.js',
79+
lastReadTime: 0,
80+
},
81+
],
82+
},
83+
]
84+
85+
const fileContext = {
86+
fileTree,
87+
fileTokenScores: {},
88+
} as unknown as ProjectFileContext
89+
90+
const result = truncateFileTreeBasedOnTokenBudget({
91+
fileContext,
92+
tokenBudget: 5000,
93+
logger: mockLogger,
94+
})
95+
96+
expect(result.printedTree).toContain('main.ts')
97+
expect(result.printedTree).not.toContain('bundle.min.js')
98+
expect(result.printedTree).not.toContain('dist')
99+
})
100+
101+
test('truncates depth-based when token budget is very small', () => {
102+
const fileTree: FileTreeNode[] = Array.from({ length: 100 }, (_, i) => ({
103+
name: `file_${i}.ts`,
104+
type: 'file',
105+
filePath: `src/deep/nested/sub/path/file_${i}.ts`,
106+
lastReadTime: 0,
107+
}))
108+
109+
const fileContext = {
110+
fileTree,
111+
fileTokenScores: {},
112+
} as unknown as ProjectFileContext
113+
114+
const result = truncateFileTreeBasedOnTokenBudget({
115+
fileContext,
116+
tokenBudget: 50,
117+
logger: mockLogger,
118+
})
119+
120+
expect(result.tokenCount).toBeLessThanOrEqual(150)
121+
expect(result.truncationLevel).toBe('depth-based')
122+
})
123+
})

packages/agent-runtime/src/system-prompt/truncate-file-tree.ts

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
} from '@codebuff/common/util/file'
55
import { sampleSizeWithSeed } from '@codebuff/common/util/random'
66

7-
import { countTokens, countTokensJson } from '../util/token-counter'
7+
import { countTokens } from '../util/token-counter'
88

99
import type { Logger } from '@codebuff/common/types/contracts/logger'
1010
import type {
@@ -32,7 +32,7 @@ export const truncateFileTreeBasedOnTokenBudget = (params: {
3232
const filteredTree = removeUnimportantFiles(fileTree)
3333

3434
const treeWithTokens = printFileTreeWithTokens(filteredTree, fileTokenScores)
35-
const treeWithTokensCount = countTokensJson(treeWithTokens)
35+
const treeWithTokensCount = countTokens(treeWithTokens)
3636

3737
if (treeWithTokensCount <= tokenBudget) {
3838
return {
@@ -43,14 +43,14 @@ export const truncateFileTreeBasedOnTokenBudget = (params: {
4343
}
4444

4545
const printedFilteredTree = printFileTree(filteredTree)
46-
const filteredTreeNoTokensCount = countTokensJson(printedFilteredTree)
46+
const filteredTreeNoTokensCount = countTokens(printedFilteredTree)
4747

4848
if (filteredTreeNoTokensCount <= tokenBudget) {
4949
const filteredTreeWithTokens = printFileTreeWithTokens(
5050
filteredTree,
5151
fileTokenScores,
5252
)
53-
const filteredTreeWithTokensCount = countTokensJson(filteredTreeWithTokens)
53+
const filteredTreeWithTokensCount = countTokens(filteredTreeWithTokens)
5454
if (filteredTreeWithTokensCount <= tokenBudget) {
5555
if (DEBUG) {
5656
logger.debug(
@@ -120,10 +120,11 @@ export const truncateFileTreeBasedOnTokenBudget = (params: {
120120

121121
// Sample 30 random files and count their tokens together
122122
const sampleCount = Math.min(30, sortedFiles.length)
123+
const sampleSeed = `${sortedFiles.length}:${sampleCount}:${sortedFiles[0]?.path ?? ''}:${sortedFiles[sortedFiles.length - 1]?.path ?? ''}`
123124
const sampleFiles = sampleSizeWithSeed(
124125
sortedFiles,
125126
sampleCount,
126-
JSON.stringify(sortedFiles) + JSON.stringify(sampleCount),
127+
sampleSeed,
127128
)
128129
const sampleText = sampleFiles.map((f) => f.node.name).join(' ')
129130
const sampleTokens = countTokens(sampleText)
@@ -168,7 +169,7 @@ export const truncateFileTreeBasedOnTokenBudget = (params: {
168169
.filter((n): n is FileTreeNode => n !== null)
169170

170171
currentPrintedTree = printFileTree(currentTree)
171-
currentTokenCount = countTokensJson(currentPrintedTree)
172+
currentTokenCount = countTokens(currentPrintedTree)
172173

173174
// Safety check - if we're not making progress, break
174175
if (currentTokenCount >= previousTokenCount) {
@@ -241,7 +242,7 @@ function pruneFileTokenScores(params: {
241242
.sort((a, b) => a.score - b.score)
242243

243244
let printedTree = printFileTreeWithTokens(fileTree, fileTokenScores)
244-
let totalTokens = countTokensJson(printedTree)
245+
let totalTokens = countTokens(printedTree)
245246

246247
if (totalTokens <= tokenBudget) {
247248
return { pruned: fileTokenScores, printedTree, tokenCount: totalTokens }
@@ -263,7 +264,7 @@ function pruneFileTokenScores(params: {
263264

264265
let index = initialKeepIndex
265266
printedTree = printFileTreeWithTokens(fileTree, pruned)
266-
totalTokens = countTokensJson(printedTree)
267+
totalTokens = countTokens(printedTree)
267268

268269
while (totalTokens > tokenBudget && index < sortedTokens.length) {
269270
const remainingToRemove = totalTokens - tokenBudget
@@ -282,7 +283,7 @@ function pruneFileTokenScores(params: {
282283

283284
// Note: The below function can take a while, so we optimized to have few loop iterations.
284285
printedTree = printFileTreeWithTokens(fileTree, pruned)
285-
totalTokens = countTokensJson(printedTree)
286+
totalTokens = countTokens(printedTree)
286287
index += batchSize
287288
}
288289

@@ -309,9 +310,8 @@ const removeUnimportantFiles = (fileTree: FileTreeNode[]): FileTreeNode[] => {
309310
if (node.type === 'directory') {
310311
// Filter out common build/cache directories
311312
const dirPath = node.filePath.toLowerCase()
312-
const isUnimportantDir = unimportantExtensions.some(
313-
(ext) =>
314-
ext.startsWith('/') && ext.endsWith('/') && dirPath.includes(ext),
313+
const isUnimportantDir = UNIMPORTANT_DIR_PATTERNS.some((dir) =>
314+
dirPath.includes(dir),
315315
)
316316
if (isUnimportantDir) {
317317
return false
@@ -323,15 +323,26 @@ const removeUnimportantFiles = (fileTree: FileTreeNode[]): FileTreeNode[] => {
323323
}
324324

325325
const filePath = node.filePath.toLowerCase()
326-
return !unimportantExtensions.some(
327-
(ext) => !ext.startsWith('/') && filePath.endsWith(ext),
328-
)
326+
return !UNIMPORTANT_EXTENSIONS.some((ext) => filePath.endsWith(ext))
329327
}
330328

331329
return fileTree.filter(shouldKeepFile)
332330
}
333331

334-
const unimportantExtensions = [
332+
const UNIMPORTANT_DIR_PATTERNS = [
333+
// Build output directories
334+
'/dist/',
335+
'/build/',
336+
'/out/',
337+
'/target/',
338+
339+
// Package manager directories
340+
'/node_modules/',
341+
'/.venv/',
342+
'/vendor/',
343+
] as const
344+
345+
const UNIMPORTANT_EXTENSIONS = [
335346
// Generated JavaScript/TypeScript files
336347
'.min.js',
337348
'.min.css',
@@ -356,17 +367,6 @@ const unimportantExtensions = [
356367
'.gem',
357368
'.rbc',
358369

359-
// Build output directories
360-
'/dist/',
361-
'/build/',
362-
'/out/',
363-
'/target/',
364-
365-
// Package manager directories
366-
'/node_modules/',
367-
'/.venv/',
368-
'/vendor/',
369-
370370
// Logs and temporary files
371371
'.log',
372372
'.tmp',
@@ -394,7 +394,6 @@ const unimportantExtensions = [
394394
'.exe',
395395
'.dll',
396396
'.lib',
397-
'.so',
398397

399398
// Media and binary files
400399
'.jpg',
@@ -411,4 +410,4 @@ const unimportantExtensions = [
411410
'.tiff',
412411
'.tif',
413412
'.webp',
414-
]
413+
] as const

0 commit comments

Comments
 (0)