Skip to content

Commit 3c0cca2

Browse files
Handle empty filters in ListDirectoryTool and add associated test cases (#1285)
<!-- Thank you for opening a pull request! Please add a brief description of the proposed change here. Also, please tick the appropriate points in the checklist below. --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> [KG-628](https://youtrack.jetbrains.com/issue/KG-628) ListDirectoryTool: filter="" excludes files from the output ## Breaking Changes <!-- Will users need to update their code or configurations? --> --- #### Type of the changes - [ ] New feature (non-breaking change which adds functionality) - [x ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Tests improvement - [ ] Refactoring #### Checklist - [ x] The pull request has a description of the proposed change - [x ] I read the [Contributing Guidelines](https://github.com/JetBrains/koog/blob/main/CONTRIBUTING.md) before opening the pull request - [x ] The pull request uses **`develop`** as the base branch - [ x] Tests for the changes have been added - [ x] All new and existing tests passed ##### Additional steps for pull requests adding a new feature - [ ] An issue describing the proposed change exists - [ ] The pull request includes a link to the issue - [ ] The change was discussed and approved in the issue - [ ] Docs have been added / updated
1 parent d07dcd9 commit 3c0cca2

File tree

2 files changed

+121
-21
lines changed

2 files changed

+121
-21
lines changed

agents/agents-ext/src/commonMain/kotlin/ai/koog/agents/ext/tool/file/ListDirectoryTool.kt

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,85 @@ public class ListDirectoryTool<Path>(private val fs: FileSystemProvider.ReadOnly
2626
resultSerializer = Result.serializer(),
2727
name = "__list_directory__",
2828
description = """
29-
Lists files and subdirectories in a directory. READ-ONLY - never modifies anything.
29+
List a directory as a tree so an agent can *orient itself* in an unknown filesystem/repo and decide what to read next.
3030
31-
Use this to:
32-
- See what files exist before reading or creating
33-
- Understand project structure
34-
- Find specific files with patterns
31+
This is usually the first tool to call:
32+
- Find where code, configs, and docs live
33+
- Confirm filenames and exact paths before reading/editing
34+
- Narrow the search space with a glob instead of dumping huge directory listings
3535
36-
Returns a tree showing all contents with sizes and metadata.
36+
Recommended agent workflow:
37+
1) Call with a small `depth` (often `1` or `2`) to understand the top-level layout.
38+
2) If you need to locate specific files, add a `filter` (glob) and increase `depth` to '5' or more.
39+
3) Once you see the exact path(s), switch to other tools to work with content.
40+
41+
This tool does NOT:
42+
- Return file contents
43+
- Search inside files (it only matches on paths via `filter`)
44+
- Modify the filesystem (read-only)
45+
46+
Common pitfalls:
47+
- `filter="*.js"` only matches files directly under `absolutePath`. For “any depth”, use `filter="**/*.js"`.
48+
- Glob does not override `depth`. If files exist deeper than the traversal can reach, you’ll get “no matches”.
49+
50+
Returns a structured tree rooted at the requested directory.
3751
""".trimIndent()
3852
) {
3953

4054
/**
4155
* Specifies which directory to list and how to traverse its contents.
4256
*
43-
* @property path absolute filesystem path to the target directory
44-
* @property depth how many levels deep to traverse (1 = direct children only, 2 = include subdirectories, etc.), defaults to 1
45-
* @property filter glob pattern to match specific files/folders (e.g., "*.kt" for Kotlin files), defaults to null
57+
* @property absolutePath absolute filesystem path to the target directory
58+
* @property depth how many levels deep to traverse (1 = direct children only, 2 = include subdirectories, etc.),
59+
* defaults to 1; single-child directory chains may be collapsed without consuming depth
60+
* @property filter optional glob pattern (case-insensitive) matched against normalized relative paths (from
61+
* [absolutePath]) using `/` as a separator; defaults to null (no filtering)
4662
*/
4763
@Serializable
4864
public data class Args(
49-
@property:LLMDescription("Absolute path to the directory you want to list (e.g., /home/user/project)")
50-
val path: String,
51-
@property:LLMDescription("How many levels deep to go. 1 = only direct contents, 2 = include subdirectories, etc. Default is 1")
65+
@property:LLMDescription(
66+
"""
67+
Absolute path to the directory to list.
68+
Requirements:
69+
- Must be an absolute path (not relative)
70+
- Must point to a directory (not a file)
71+
"""
72+
)
73+
val absolutePath: String,
74+
@property:LLMDescription(
75+
"""
76+
Maximum traversal depth (> 0). Default is `1`.
77+
Guidance:
78+
- Start with `1` to avoid large outputs.
79+
- Increase when you need to see inside subfolders, but prefer adding a `filter` to keep results small.
80+
"""
81+
)
5282
val depth: Int = 1,
53-
@property:LLMDescription("Glob pattern to match files/folders. Examples: '*.txt' for text files, '**/*.kt' for all Kotlin files at any depth")
83+
@property:LLMDescription(
84+
"""
85+
Optional glob filter for narrowing results (case-insensitive). Use `null` or `""` to disable filtering.
86+
87+
What it matches:
88+
- The pattern is matched against each entry’s *relative path* from `absolutePath` (normalized to `/`, even on Windows).
89+
Example relative paths: `README.md`, `src/main/kotlin/App.kt`, `tests/__init__.py`.
90+
91+
What you get back:
92+
- Matching files are included.
93+
- Directories are included when they contain matching entries (to preserve structure).
94+
- If `depth` is too small to reach matches, you may get a “no matches” error even if the files exist deeper.
95+
96+
Supported syntax:
97+
- `*` matches within a single path segment (does not cross `/`)
98+
- `**` can cross `/` (any depth)
99+
- `?`, `[...]`, `[!...]`, `{a,b}` alternatives are supported
100+
101+
Practical examples:
102+
- `"**/*.java"`: all Java files anywhere under `absolutePath`
103+
- `"*/*.ts"`: TypeScript files exactly 1 folder below `absolutePath`
104+
- `"*/Test*"`: test files like `test/TestMain.cs`
105+
- `"**/{build.gradle.kts,settings.gradle.kts}"`: find Gradle build entrypoints
106+
"""
107+
)
54108
val filter: String? = null
55109
)
56110

@@ -82,23 +136,25 @@ public class ListDirectoryTool<Path>(private val fs: FileSystemProvider.ReadOnly
82136
override suspend fun execute(args: Args): Result {
83137
validate(args.depth > 0) { "Depth must be at least 1 (got ${args.depth})" }
84138

85-
val path = fs.fromAbsolutePathString(args.path)
86-
val metadata = validateNotNull(fs.metadata(path)) { "Path does not exist: ${args.path}" }
139+
val path = fs.fromAbsolutePathString(args.absolutePath)
140+
val metadata = validateNotNull(fs.metadata(path)) { "Path does not exist: ${args.absolutePath}" }
87141

88142
validate(metadata.type == FileMetadata.FileType.Directory) {
89-
"Path is not a directory: ${args.path} (it's a ${metadata.type})"
143+
"Path is not a directory: ${args.absolutePath} (it's a ${metadata.type})"
90144
}
91145

92146
val entry = buildDirectoryTree(
93147
fs = fs,
94148
start = path,
95149
startMetadata = metadata,
96150
maxDepth = args.depth,
97-
filter = args.filter?.let { GlobPattern(it, caseSensitive = false) }
151+
filter = args.filter?.ifEmpty { null }?.let {
152+
GlobPattern(pattern = it, caseSensitive = false)
153+
}
98154
)
99155

100156
validate(entry != null) {
101-
"No files or directories match the pattern '${args.filter}' in ${args.path}"
157+
"No files or directories match the pattern '${args.filter}' in ${args.absolutePath}"
102158
}
103159

104160
return Result(entry as FileSystemEntry.Folder)

agents/agents-ext/src/jvmTest/kotlin/ai/koog/agents/ext/tool/file/ListDirectoryToolJvmTest.kt

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class ListDirectoryToolJvmTest {
3333
@Test
3434
fun `Args uses correct defaults`() {
3535
val args = ListDirectoryTool.Args("/tmp/test")
36-
assertEquals("/tmp/test", args.path)
36+
assertEquals("/tmp/test", args.absolutePath)
3737
assertEquals(1, args.depth)
3838
assertNull(args.filter)
3939
}
@@ -43,7 +43,7 @@ class ListDirectoryToolJvmTest {
4343
val descriptor = tool.descriptor
4444
assertEquals("__list_directory__", descriptor.name)
4545
assertTrue(descriptor.description.isNotEmpty())
46-
assertEquals(listOf("path"), descriptor.requiredParameters.map { it.name })
46+
assertEquals(listOf("absolutePath"), descriptor.requiredParameters.map { it.name })
4747
assertEquals(setOf("depth", "filter"), descriptor.optionalParameters.map { it.name }.toSet())
4848
}
4949

@@ -105,7 +105,7 @@ class ListDirectoryToolJvmTest {
105105
dir.resolve("README.md").createFile().writeText("hello") // 5 bytes
106106
dir.resolve("LICENSE.txt").createFile().writeText("MIT") // 3 bytes
107107

108-
val resultText = tool.encodeResultToString(list(dir, depth = 2))
108+
val resultText = tool.encodeResultToString(list(dir, depth = 1))
109109

110110
// Expected:
111111
// /path/to/project/
@@ -454,6 +454,50 @@ class ListDirectoryToolJvmTest {
454454
assertEquals(expectedText, resultText)
455455
}
456456

457+
@Test
458+
fun `empty filter outputs all files`() = runBlocking {
459+
// Structure:
460+
// project/
461+
// ├── LICENSE.txt
462+
// └── README.md
463+
val dir = createDir("project")
464+
dir.resolve("README.md").createFile().writeText("hello") // 5 bytes
465+
dir.resolve("LICENSE.txt").createFile().writeText("MIT") // 3 bytes
466+
467+
val resultText = tool.encodeResultToString(list(dir, depth = 1, filter = ""))
468+
469+
// Expected:
470+
// /path/to/project/
471+
// LICENSE.txt (<0.1 KiB, 1 line)
472+
// README.md (<0.1 KiB, 1 line)
473+
val expectedText = """
474+
${dir.toAbsolutePath().toString().norm()}/
475+
LICENSE.txt (<0.1 KiB, 1 line)
476+
README.md (<0.1 KiB, 1 line)
477+
""".trimIndent()
478+
479+
assertEquals(expectedText, resultText)
480+
}
481+
482+
@Test
483+
fun `filter is case insensitive`() = runBlocking {
484+
// Structure:
485+
// project/
486+
// ├── LICENSE.txt
487+
// └── README.md
488+
val dir = createDir("project")
489+
dir.resolve("README.md").createFile().writeText("hello") // 5 bytes
490+
dir.resolve("LICENSE.txt").createFile().writeText("MIT") // 3 bytes
491+
492+
val resultText = tool.encodeResultToString(list(dir, depth = 1, filter = "read*"))
493+
494+
// Expected:
495+
// /path/to/project/README.md (<0.1 KiB, 1 line)
496+
val expectedText = "${dir.toAbsolutePath().toString().norm()}/README.md (<0.1 KiB, 1 line)"
497+
498+
assertEquals(expectedText, resultText)
499+
}
500+
457501
@Test
458502
fun `complex nested structure with mixed content`() = runBlocking {
459503
// Structure:

0 commit comments

Comments
 (0)