Skip to content

Commit 26835cc

Browse files
committed
read_subtree tool
1 parent da781b1 commit 26835cc

File tree

11 files changed

+441
-0
lines changed

11 files changed

+441
-0
lines changed

.agents/types/tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type ToolName =
1111
| 'lookup_agent_info'
1212
| 'read_docs'
1313
| 'read_files'
14+
| 'read_subtree'
1415
| 'run_file_change_hooks'
1516
| 'run_terminal_command'
1617
| 'set_messages'
@@ -36,6 +37,7 @@ export interface ToolParamsMap {
3637
lookup_agent_info: LookupAgentInfoParams
3738
read_docs: ReadDocsParams
3839
read_files: ReadFilesParams
40+
read_subtree: ReadSubtreeParams
3941
run_file_change_hooks: RunFileChangeHooksParams
4042
run_terminal_command: RunTerminalCommandParams
4143
set_messages: SetMessagesParams
@@ -130,6 +132,16 @@ export interface ReadFilesParams {
130132
paths: string[]
131133
}
132134

135+
/**
136+
* Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.
137+
*/
138+
export interface ReadSubtreeParams {
139+
/** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */
140+
paths?: string[]
141+
/** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */
142+
maxTokens?: number
143+
}
144+
133145
/**
134146
* Parameters for run_file_change_hooks tool
135147
*/

common/src/templates/initial-agents-dir/types/tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type ToolName =
1111
| 'lookup_agent_info'
1212
| 'read_docs'
1313
| 'read_files'
14+
| 'read_subtree'
1415
| 'run_file_change_hooks'
1516
| 'run_terminal_command'
1617
| 'set_messages'
@@ -36,6 +37,7 @@ export interface ToolParamsMap {
3637
lookup_agent_info: LookupAgentInfoParams
3738
read_docs: ReadDocsParams
3839
read_files: ReadFilesParams
40+
read_subtree: ReadSubtreeParams
3941
run_file_change_hooks: RunFileChangeHooksParams
4042
run_terminal_command: RunTerminalCommandParams
4143
set_messages: SetMessagesParams
@@ -130,6 +132,16 @@ export interface ReadFilesParams {
130132
paths: string[]
131133
}
132134

135+
/**
136+
* Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.
137+
*/
138+
export interface ReadSubtreeParams {
139+
/** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */
140+
paths?: string[]
141+
/** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */
142+
maxTokens?: number
143+
}
144+
133145
/**
134146
* Parameters for run_file_change_hooks tool
135147
*/

common/src/tools/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const toolNames = [
3131
'lookup_agent_info',
3232
'read_docs',
3333
'read_files',
34+
'read_subtree',
3435
'run_file_change_hooks',
3536
'run_terminal_command',
3637
'set_messages',
@@ -56,6 +57,7 @@ export const publishedTools = [
5657
'lookup_agent_info',
5758
'read_docs',
5859
'read_files',
60+
'read_subtree',
5961
'run_file_change_hooks',
6062
'run_terminal_command',
6163
'set_messages',

common/src/tools/list.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { listDirectoryParams } from './params/tool/list-directory'
1313
import { lookupAgentInfoParams } from './params/tool/lookup-agent-info'
1414
import { readDocsParams } from './params/tool/read-docs'
1515
import { readFilesParams } from './params/tool/read-files'
16+
import { readSubtreeParams } from './params/tool/read-subtree'
1617
import { runFileChangeHooksParams } from './params/tool/run-file-change-hooks'
1718
import { runTerminalCommandParams } from './params/tool/run-terminal-command'
1819
import { setMessagesParams } from './params/tool/set-messages'
@@ -52,6 +53,7 @@ export const $toolParams = {
5253
lookup_agent_info: lookupAgentInfoParams,
5354
read_docs: readDocsParams,
5455
read_files: readFilesParams,
56+
read_subtree: readSubtreeParams,
5557
run_file_change_hooks: runFileChangeHooksParams,
5658
run_terminal_command: runTerminalCommandParams,
5759
set_messages: setMessagesParams,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import z from 'zod/v4'
2+
3+
import type { $ToolParams } from '../../constants'
4+
5+
const toolName = 'read_subtree'
6+
const endsAgentStep = true
7+
export const readSubtreeParams = {
8+
toolName,
9+
endsAgentStep,
10+
parameters: z
11+
.object({
12+
paths: z
13+
.array(z.string().min(1))
14+
.optional()
15+
.describe(
16+
`List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used.`,
17+
),
18+
maxTokens: z
19+
.number()
20+
.int()
21+
.positive()
22+
.default(4000)
23+
.describe(
24+
`Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories.`,
25+
),
26+
})
27+
.describe(
28+
`Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.`,
29+
),
30+
outputs: z.tuple([
31+
z.object({
32+
type: z.literal('json'),
33+
value: z.array(
34+
z.union([
35+
z.object({
36+
path: z.string(),
37+
type: z.literal('directory'),
38+
printedTree: z.string(),
39+
tokenCount: z.number(),
40+
truncationLevel: z.enum([
41+
'none',
42+
'unimportant-files',
43+
'tokens',
44+
'depth-based',
45+
]),
46+
}),
47+
z.object({
48+
path: z.string(),
49+
type: z.literal('file'),
50+
variables: z.array(z.string()),
51+
}),
52+
z.object({
53+
path: z.string(),
54+
errorMessage: z.string(),
55+
}),
56+
]),
57+
),
58+
}),
59+
]),
60+
} satisfies $ToolParams

npm-app/src/utils/tool-renderers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ export const toolRenderers: Record<ToolName, ToolCallRenderer> = {
180180
return '\n\n'
181181
},
182182
},
183+
read_subtree: {
184+
...defaultToolCallRenderer,
185+
},
183186
read_docs: {
184187
...defaultToolCallRenderer,
185188
},

packages/agent-runtime/src/tools/definitions/list.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { listDirectoryTool } from './tool/list-directory'
1212
import { lookupAgentInfoTool } from './tool/lookup-agent-info'
1313
import { readDocsTool } from './tool/read-docs'
1414
import { readFilesTool } from './tool/read-files'
15+
import { readSubtreeTool } from './tool/read-subtree'
1516
import { runFileChangeHooksTool } from './tool/run-file-change-hooks'
1617
import { runTerminalCommandTool } from './tool/run-terminal-command'
1718
import { setMessagesTool } from './tool/set-messages'
@@ -43,6 +44,7 @@ const toolDescriptions = {
4344
lookup_agent_info: lookupAgentInfoTool,
4445
read_docs: readDocsTool,
4546
read_files: readFilesTool,
47+
read_subtree: readSubtreeTool,
4648
run_file_change_hooks: runFileChangeHooksTool,
4749
run_terminal_command: runTerminalCommandTool,
4850
set_messages: setMessagesTool,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getToolCallString } from '@codebuff/common/tools/utils'
2+
3+
import type { ToolDescription } from '../tool-def-type'
4+
5+
const toolName = 'read_subtree'
6+
export const readSubtreeTool = {
7+
toolName,
8+
description: `
9+
Example:
10+
${getToolCallString(toolName, {
11+
paths: ['src', 'package.json'],
12+
maxTokens: 4000,
13+
})}
14+
15+
Purpose: Read a directory subtree and return a blob containing subdirectories, file names, and parsed variable/functions names from source files. For files, return only the parsed variable names. If no paths are provided, returns the entire project tree. The output is truncated to fit within the provided token budget.
16+
`.trim(),
17+
} satisfies ToolDescription
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { describe, it, expect } from 'bun:test'
2+
3+
import { handleReadSubtree } from '../tool/read-subtree'
4+
import { getStubProjectFileContext } from '@codebuff/common/util/file'
5+
6+
import type { CodebuffToolCall } from '@codebuff/common/tools/list'
7+
import type { Logger } from '@codebuff/common/types/contracts/logger'
8+
9+
function createLogger(): Logger {
10+
return {
11+
debug: () => {},
12+
info: () => {},
13+
warn: () => {},
14+
error: () => {},
15+
}
16+
}
17+
18+
function buildMockFileContext() {
19+
const ctx = getStubProjectFileContext()
20+
ctx.fileTree = [
21+
{
22+
name: 'src',
23+
type: 'directory',
24+
filePath: 'src',
25+
children: [
26+
{
27+
name: 'index.ts',
28+
type: 'file',
29+
filePath: 'src/index.ts',
30+
lastReadTime: 0,
31+
},
32+
{
33+
name: 'util.ts',
34+
type: 'file',
35+
filePath: 'src/util.ts',
36+
lastReadTime: 0,
37+
},
38+
],
39+
},
40+
{
41+
name: 'package.json',
42+
type: 'file',
43+
filePath: 'package.json',
44+
lastReadTime: 0,
45+
},
46+
]
47+
ctx.fileTokenScores = {
48+
'src/index.ts': { beta: 2.0, alpha: 1.0 },
49+
'src/util.ts': { helper: 3.0 },
50+
'package.json': {},
51+
}
52+
return ctx
53+
}
54+
55+
describe('handleReadSubtree', () => {
56+
it('returns a directory subtree blob with tokens for a directory path', async () => {
57+
const fileContext = buildMockFileContext()
58+
const logger = createLogger()
59+
60+
const toolCall: CodebuffToolCall<'read_subtree'> = {
61+
toolName: 'read_subtree',
62+
toolCallId: 'tc-1',
63+
input: { paths: ['src'], maxTokens: 50000 },
64+
}
65+
66+
const { result } = handleReadSubtree({
67+
previousToolCallFinished: Promise.resolve(),
68+
toolCall,
69+
fileContext,
70+
logger,
71+
})
72+
73+
const output = await result
74+
expect(Array.isArray(output)).toBe(true)
75+
expect(output[0].type).toBe('json')
76+
const value = output[0].value as any[]
77+
const dirEntry = value.find((v) => v.type === 'directory' && v.path === 'src')
78+
expect(dirEntry).toBeTruthy()
79+
expect(typeof dirEntry.printedTree).toBe('string')
80+
expect(dirEntry.printedTree).toContain('src/')
81+
expect(dirEntry.printedTree).toContain('index.ts')
82+
expect(typeof dirEntry.tokenCount).toBe('number')
83+
expect(['none', 'unimportant-files', 'tokens', 'depth-based']).toContain(
84+
dirEntry.truncationLevel,
85+
)
86+
})
87+
88+
it('returns parsed variable names for a file path', async () => {
89+
const fileContext = buildMockFileContext()
90+
const logger = createLogger()
91+
92+
const toolCall: CodebuffToolCall<'read_subtree'> = {
93+
toolName: 'read_subtree',
94+
toolCallId: 'tc-2',
95+
input: { paths: ['src/index.ts'], maxTokens: 50000 },
96+
}
97+
98+
const { result } = handleReadSubtree({
99+
previousToolCallFinished: Promise.resolve(),
100+
toolCall,
101+
fileContext,
102+
logger,
103+
})
104+
105+
const output = await result
106+
expect(output[0].type).toBe('json')
107+
const value = output[0].value as any[]
108+
const fileEntry = value.find(
109+
(v) => v.type === 'file' && v.path === 'src/index.ts',
110+
)
111+
expect(fileEntry).toBeTruthy()
112+
expect(Array.isArray(fileEntry.variables)).toBe(true)
113+
// Sorted by descending score: beta (2.0) before alpha (1.0)
114+
expect(fileEntry.variables[0]).toBe('beta')
115+
expect(fileEntry.variables).toContain('alpha')
116+
})
117+
118+
it('returns an error object for a missing path', async () => {
119+
const fileContext = buildMockFileContext()
120+
const logger = createLogger()
121+
122+
const toolCall: CodebuffToolCall<'read_subtree'> = {
123+
toolName: 'read_subtree',
124+
toolCallId: 'tc-3',
125+
input: { paths: ['does-not-exist'], maxTokens: 50000 },
126+
}
127+
128+
const { result } = handleReadSubtree({
129+
previousToolCallFinished: Promise.resolve(),
130+
toolCall,
131+
fileContext,
132+
logger,
133+
})
134+
135+
const output = await result
136+
expect(output[0].type).toBe('json')
137+
const value = output[0].value as any[]
138+
const errEntry = value.find((v) => v.path === 'does-not-exist' && v.errorMessage)
139+
expect(errEntry).toBeTruthy()
140+
expect(String(errEntry.errorMessage)).toContain('Path not found or ignored')
141+
})
142+
143+
it('honors maxTokens by reducing token count under a tiny budget', async () => {
144+
const fileContext = buildMockFileContext()
145+
const logger = createLogger()
146+
147+
// Large budget (baseline)
148+
const largeToolCall: CodebuffToolCall<'read_subtree'> = {
149+
toolName: 'read_subtree',
150+
toolCallId: 'tc-4a',
151+
input: { paths: ['src'], maxTokens: 50000 },
152+
}
153+
const { result: largeResultPromise } = handleReadSubtree({
154+
previousToolCallFinished: Promise.resolve(),
155+
toolCall: largeToolCall,
156+
fileContext,
157+
logger,
158+
})
159+
const largeOutput = await largeResultPromise
160+
expect(largeOutput[0].type).toBe('json')
161+
const largeValue = largeOutput[0].value as any[]
162+
const largeDirEntry = largeValue.find((v) => v.type === 'directory' && v.path === 'src')
163+
expect(largeDirEntry).toBeTruthy()
164+
165+
// Tiny budget
166+
const tinyBudget = 5
167+
const smallToolCall: CodebuffToolCall<'read_subtree'> = {
168+
toolName: 'read_subtree',
169+
toolCallId: 'tc-4b',
170+
input: { paths: ['src'], maxTokens: tinyBudget },
171+
}
172+
const { result: smallResultPromise } = handleReadSubtree({
173+
previousToolCallFinished: Promise.resolve(),
174+
toolCall: smallToolCall,
175+
fileContext,
176+
logger,
177+
})
178+
const smallOutput = await smallResultPromise
179+
expect(smallOutput[0].type).toBe('json')
180+
const smallValue = smallOutput[0].value as any[]
181+
const smallDirEntry = smallValue.find((v) => v.type === 'directory' && v.path === 'src')
182+
expect(smallDirEntry).toBeTruthy()
183+
184+
// Must honor the tiny budget
185+
expect(typeof smallDirEntry.tokenCount).toBe('number')
186+
expect(smallDirEntry.tokenCount).toBeLessThanOrEqual(tinyBudget)
187+
188+
// Typically, token count under tiny budget should be <= baseline
189+
expect(smallDirEntry.tokenCount).toBeLessThanOrEqual(largeDirEntry.tokenCount)
190+
})
191+
})

0 commit comments

Comments
 (0)