Skip to content

feat: add list_files and grep_file skills tools - #2267

Merged
EItanya merged 22 commits into
kagent-dev:mainfrom
brandonkeung:feat/grep-list-files-tools
Sep 23, 2026
Merged

EItanya merged 22 commits into
kagent-dev:mainfrom
brandonkeung:feat/grep-list-files-tools

Conversation

@brandonkeung

@brandonkeung brandonkeung commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds native, in-process list_files and grep_file tools to both the Go (go/adk/pkg/skills, go/adk/pkg/tools) and Python (kagent-skills, kagent-adk) agent runtimes, alongside the existing read_file/write_file/edit_file/bash skills tools.
  • Gives agents safe, non-privileged file visibility without depending on bash, which some deployments disable for privilege/security reasons.
  • Hardened across several review passes; fixes along the way:
    • Go: a symlink-resolution inconsistency in resolveReadPath/resolveWritePath/resolveEditPath could reject valid paths under a symlinked session root.
    • Go: NewSkillsTools failed entirely (dropping all tools) when the bash command executor couldn't be constructed, instead of just omitting bash.
    • Go: filepath.WalkDir root resolution fixed so an unresolved symlink root is actually recursed into; an err-shadowing bug that silently truncated results on a WalkDir failure fixed.
    • Both: grep_file no longer hangs indefinitely on a FIFO (or other non-regular file) with no writer connected, in both the recursive walk and single-target paths.
    • Both: matched lines are capped to 2000 chars (matching read_file's existing convention) — previously unbounded in Python, and could fail the whole search past 1MB in Go.
    • Python: GrepFileTool now uses a dedicated thread pool instead of the shared default pool, since a hung regex match can't be forcibly killed and would otherwise starve unrelated work.
    • Both: a single unreadable file or subdirectory is now a skip rather than an abort of the whole search, so one bad entry doesn't discard matches already found elsewhere in the tree — annotated as "no matches found (N entries could not be read)" so a systemic failure isn't indistinguishable from a genuinely empty search.
    • Both: a fully unreadable search root now surfaces a real error instead of a misleadingly confident empty result.
    • Per maintainer feedback, list_files/grep_file are now disabled by default and gated behind KAGENT_ENABLE_FILE_SEARCH_TOOLS (set on the Agent's env to opt in) — mirroring how bash is already effectively opt-in. read_file/write_file/edit_file/bash/skills are unaffected.

Test plan

  • Go: TestGrepContent (go/adk/pkg/skills/shell_test.go) — matches, recursion, symlink escape/resolution, FIFO hang safety, unreadable file/subdirectory/root handling, output truncation — and TestListFilesAndGrepFileTools_RunThroughADK, TestNewSkillsTools_OmitsBashWithoutSRTSettings (go/adk/pkg/tools/skills_test.go)
  • Python: unit tests in kagent-skills/kagent-adk covering the same scenarios (path traversal, recursive/ignore-case, symlink escape, FIFO hang safety, unreadable file/subdirectory/root handling, truncation, GrepFileTool timeout behavior)
  • All new regression tests verified to fail against the pre-fix code, confirming they exercise the actual bug
  • Verified end-to-end in a live kind cluster via the browser UI for both runtimes (dedicated Go- and Python-runtime test agents), including the FIFO-hang scenario and the unreadable-subdirectory/root scenarios, each invoking list_files/grep_file by name and returning correct output
  • Go/Python unit tests for the KAGENT_ENABLE_FILE_SEARCH_TOOLS flag's default-off/opt-in behavior (skills_test.go, test_skill_execution.py, new test_skills_plugin.py)
  • Verified end-to-end on a live kind cluster via A2A and the browser UI: dedicated Go- and Python-runtime test agents, with and without KAGENT_ENABLE_FILE_SEARCH_TOOLS set, confirming correct tool registration and that list_files/grep_file execute correctly when enabled

Known gaps

  • No shared Go/Python conformance fixture. 6 of the 10 review findings were
    runtime drift between the two implementations. That parity is currently held by
    prose comments naming the other runtime's identifiers, which is weaker than it
    should be for behavior that must match. Tracked in #.
  • EditFileContent ambiguity threshold diverges (pre-existing, untouched by
    this PR): Go errors only when the trimmed old_string is under 5 characters;
    Python always errors on multiple matches without replace_all.

Copilot AI review requested due to automatic review settings July 15, 2026 20:56
@brandonkeung
brandonkeung requested review from a team and supreme-gg-gg as code owners July 15, 2026 20:56
@github-actions github-actions Bot added the enhancement New feature or request label Jul 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends Kagent’s agent toolsets (Go + Python runtimes) with native, in-process filesystem visibility tools—list_files and grep_file—so agents can inspect session/skills files without relying on bash (which may be disabled). It also adjusts Go path-resolution and tool construction behavior to be more robust around symlinked roots and missing sandbox-runtime settings.

Changes:

  • Added list_files/grep_file tool descriptions and implementations for both Python (kagent-skills, kagent-adk) and Go (go/adk/pkg/skills, go/adk/pkg/tools).
  • Updated Go tool initialization to omit bash when sandbox-runtime settings are unavailable, instead of failing the entire toolset.
  • Added/expanded unit tests covering new directory listing and grep behaviors across both runtimes.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py Adds unit tests for list_dir_content / grep_content (including traversal/recursive/error cases).
python/packages/kagent-skills/src/kagent/skills/shell.py Implements list_dir_content and grep_content core logic.
python/packages/kagent-skills/src/kagent/skills/prompts.py Adds standardized prompt/description text for list_files and grep_file.
python/packages/kagent-skills/src/kagent/skills/init.py Exposes new functions/descriptions in the public skills package API.
python/packages/kagent-adk/src/kagent/adk/tools/skills_toolset.py Adds ListFilesTool/GrepFileTool to the default ADK toolset.
python/packages/kagent-adk/src/kagent/adk/tools/skills_plugin.py Ensures agents get list_files / grep_file tools when missing.
python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py Introduces ADK tool wrappers ListFilesTool and GrepFileTool wired to skills implementations.
python/packages/kagent-adk/src/kagent/adk/tools/init.py Exports the new tool classes.
go/adk/pkg/tools/skills.go Adds Go list_files/grep_file, makes bash optional, and adjusts symlink root resolution.
go/adk/pkg/tools/skills_test.go Adds tests for toolset composition and end-to-end tool invocation via functiontool.Run().
go/adk/pkg/skills/shell.go Adds Go implementations ListDirContent and GrepContent.
go/adk/pkg/skills/shell_test.go Adds unit tests for the new Go directory listing and grep functions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/adk/pkg/skills/shell.go Resolved
Comment thread go/adk/pkg/skills/shell.go Resolved
Comment thread go/adk/pkg/tools/skills.go Resolved
@brandonkeung
brandonkeung force-pushed the feat/grep-list-files-tools branch from 14ac799 to 7b55923 Compare July 15, 2026 21:01
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 16, 2026
Adds native, in-process list_files and grep_file tools to both the Go
and Python agent runtimes, alongside the existing read_file/write_file/
edit_file/bash skills tools. Gives agents safe, non-privileged file
visibility without depending on bash, which some deployments disable
for privilege/security reasons.

Also fixes two related bugs found while implementing and testing this:
- Go: a symlink-resolution inconsistency in resolveReadPath/
  resolveWritePath/resolveEditPath could reject valid paths under a
  symlinked session root.
- Go: NewSkillsTools failed entirely (dropping all tools) when the bash
  command executor couldn't be constructed, instead of omitting bash.

Signed-off-by: brandonkeung <[email protected]>
- Skip symlinked entries that resolve outside the searched root during
  recursive grep_file, in both the Go and Python implementations. A
  symlink inside an otherwise-jailed directory (e.g. from an untrusted
  skill package) could previously be followed to read file contents
  outside the intended sandbox.
- Bound the Go grep scanner's line buffer (was capped at the default
  64KiB bufio.Scanner token size, which errored on long lines such as
  minified JSON).
- Reject an empty path explicitly in the Go grep_file tool instead of
  surfacing a confusing "no file path provided" error from deeper in
  the call stack.

Signed-off-by: brandonkeung <[email protected]>
Adds the two new tools to the quick-start import example and the
Tool Workflow table, and notes the symlink-escape protection in the
Security section, matching how the existing read_file/write_file/
edit_file/bash tools are already documented there.

Signed-off-by: brandonkeung <[email protected]>
Thermos branch review turned up a real bug introduced by the previous
symlink-escape fix, plus a consolidation opportunity and a missing
timeout:

- Go: `root, err := filepath.EvalSymlinks(path)` inside GrepContent's
  `if info.IsDir()` block shadowed the outer `err`, so a WalkDir
  failure was never observed by the `err != nil` check afterward.
  Combined with an in-bounds directory symlink (which WalkDir doesn't
  recurse into, and which grepFile can't read as a file), this caused
  the walk to abort silently partway through, returning a truncated
  "success" result with no error. Fixed by not shadowing err, and by
  explicitly skipping symlinked directories instead of letting
  grepFile fail on them.
- Go: consolidated the symlink-escape containment check into a single
  shared `skillruntime.WithinRoot` helper (previously GrepContent had
  its own filepath.Rel-based check, duplicating the pre-existing
  isWithinRoot used by resolveReadPath/resolveEditPath/resolveWritePath)
  so there's one implementation of this security-relevant property
  instead of two that could drift.
- Python: grep_file's regex match now runs via asyncio.to_thread with
  a 30s asyncio.wait_for timeout, mirroring the timeout bash already
  enforces. Python's re engine backtracks and a pathological,
  agent-controlled pattern run synchronously inside an async def could
  otherwise block the whole event loop indefinitely (Go is unaffected;
  its regexp package is RE2-based and linear-time).
- Mention list_files/grep_file in the bash tool's own description in
  both languages, and log (debug level) when bash is omitted because
  the sandbox-runtime isn't configured, so its absence isn't silent.

Verified live: both Go and Python runtime pods rebuilt and redeployed,
confirmed via the UI that a recursive grep_file across a working
directory containing the skills/ symlink (the exact scenario the
shadowing bug silently broke) now correctly finds matches on both
sides of the symlink.

Signed-off-by: brandonkeung <[email protected]>
…d failures

Across several review passes on grep_file/list_files, close out the
remaining correctness gaps in the recursive-search path (Go and Python):

- Fix filepath.WalkDir root resolution so an unresolved symlink root is
  actually recursed into, and fix an err-shadowing bug that silently
  truncated results on a WalkDir failure.
- Skip non-regular files (FIFOs, sockets, devices) before opening them --
  previously a FIFO with no writer connected would hang the search
  indefinitely, in both the recursive walk and single-target paths.
- Cap matched lines to 2000 chars (matching read_file's existing
  convention); previously unbounded in Python and could fail the whole
  search past 1MB in Go.
- Give GrepFileTool a dedicated thread pool in Python instead of the
  shared default pool, since a hung regex match can't be forcibly killed
  and would otherwise starve unrelated work.
- Treat a single unreadable file or subdirectory as a skip rather than
  aborting the whole search, so one bad entry doesn't discard matches
  already found elsewhere in the tree. Annotate "no matches found (N
  entries could not be read)" when skips occurred, so a systemic failure
  isn't indistinguishable from a genuinely empty search.
- Surface a real error, instead of a misleadingly confident empty
  result, when the search root itself is unreadable.
- Extract Go's classifyWalkEntry and Python's _resolve_working_path
  helpers to keep the now-more-involved walk logic readable.

Regression tests added for each fix above, verified to fail against the
prior code.

Signed-off-by: brandonkeung <[email protected]>
@brandonkeung
brandonkeung force-pushed the feat/grep-list-files-tools branch from 8f8fb8c to 041e747 Compare July 16, 2026 19:08

@EItanya EItanya left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity, why do we need this? Can't it just use the shell tools? This is already in a sandbox so why would shell be disabled?

A maintainer asked that list_files/grep_file default to disabled rather
than being registered unconditionally, since they give an agent broader
filesystem visibility than read_file/write_file/edit_file. Both runtimes
now check a single env var, KAGENT_ENABLE_FILE_SEARCH_TOOLS (off by
default, same true-ish values "1"/"t"/"true" case-insensitive in both
languages), before registering the two tools. read_file/write_file/
edit_file/skills/bash are unaffected.

Verified end-to-end on a live kind cluster: dedicated Go- and
Python-runtime test agents with and without the env var set, confirming
the tools are absent/present in the registered tool list and functional
when enabled.

Signed-off-by: brandonkeung <[email protected]>
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 23, 2026
@brandonkeung

Copy link
Copy Markdown
Contributor Author

Just out of curiosity, why do we need this? Can't it just use the shell tools? This is already in a sandbox so why would shell be disabled?

Some deployments turn bash off anyway for extra tightness (fewer arbitrary-command-execution surfaces even inside a sandbox), and list_files/grep_file were originally always-on regardless of that choice, which undercut it. Pushed a change gating both behind KAGENT_ENABLE_FILE_SEARCH_TOOLS (default off) so they follow the same opt-in posture as bash instead of being an end-run around it.

…list_files

Addresses 4 issues mesutoezdil found in review on PR kagent-dev#2267:
- ListDirContent listed a directory symlink (e.g. every session's "skills"
  entry) as a file instead of a directory, since entry.IsDir() doesn't
  follow symlinks. Now stats symlink entries to classify them correctly,
  matching Python's existing symlink-following behavior.
- classifyWalkEntry verified a walked entry's resolved, in-bounds target,
  but grepFile then reopened the original unresolved path -- a
  verify-then-use gap where the symlink's target could differ between the
  check and the read. grepFile now reads the resolved path that was
  actually verified. This narrows the race but doesn't fully eliminate it
  (documented in a comment on classifyWalkEntry); closing it completely
  would need platform-specific work disproportionate to this file's
  existing security bar.
- Go and Python both silently dropped the "N entries could not be read"
  note whenever there were also real matches, only surfacing it when the
  result was otherwise empty -- masking partial failures. Both now append
  it alongside real matches too.

Verified end-to-end on a live kind cluster via A2A against redeployed
Go- and Python-runtime test agents, extracting raw tool function_response
payloads (not model-summarized text) to confirm each fix's actual
behavior, plus a targeted regression check confirming symlink-escape
protection still holds after the refactor.

Signed-off-by: brandonkeung <[email protected]>
results.extend(grep_file(file_or_dir_path))

if not results:
if skipped:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in e3f9c76

Comment thread go/adk/pkg/skills/shell.go Outdated
}
return nil
})
if err == nil && skipped > 0 && result.Len() == 0 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in e3f9c76

Comment thread go/adk/pkg/skills/shell.go Outdated
// A read error on one file (permission denied, a line
// exceeding the scan buffer, etc.) shouldn't abort matches
// already found elsewhere in the tree.
if grepErr := grepFile(p); grepErr != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in e3f9c76

Comment thread go/adk/pkg/tools/shell.go
}

var result strings.Builder
for _, entry := range entries {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in e3f9c76

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

This pull request has been marked as stale because of no activity in the last 15 days. It will be closed in the next 5 days unless it is tagged "no stalebot" or other activity occurs.

@github-actions github-actions Bot added the stale This issue or PR has become stale label Aug 9, 2026
@brandonkeung

Copy link
Copy Markdown
Contributor Author

bump (prevent auto close PR)

…s-tools

Signed-off-by: brandonkeung <[email protected]>

# Conflicts:
#	python/packages/kagent-adk/src/kagent/adk/tools/__init__.py
@brandonkeung
brandonkeung force-pushed the feat/grep-list-files-tools branch from b02c1af to b4f76af Compare August 31, 2026 21:37
Comment thread go/adk/pkg/tools/shell.go
}
}

info, err := entry.Info()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. entry.Info() is Lstat-based, so for a symlink it returned the length of the stored target path rather than the file's size — and entry.IsDir() reported the link rather than its target, so a symlinked directory also lost its trailing /.

Fixed in 07e4d62. ListDirContent now branches on entry.Type()&fs.ModeSymlink and uses os.Stat, which follows the link — matching Python's Path.is_dir()/Path.stat(), which follow by default. A broken link prints the bare name, as Python does when its stat() raises there.

go/adk/pkg/tools/shell.go:233-246. Covered by TestListDirContent.

Comment thread go/adk/pkg/tools/shell.go Outdated
// one as a file, so skip it rather than treating it as an error.
return walkEntrySkip, ""
}
if !fi.Mode().IsRegular() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — and rather than copy grep's check across, both tools now share one reader, scanFileLines, which stats before opening and rejects anything non-regular. Putting it there means no future caller can omit it.

That also closed a second divergence in the same seam that wasn't visible from this line: read_file was on bufio's default 64KB token limit while grep set a larger buffer, so read_file failed outright on any file with a long line (minified JSON, for instance). Both now share maxLineBytes.

d5fb009, go/adk/pkg/tools/shell.go:38-71. Tests: TestReadFileContent_FIFOReturnsErrorInsteadOfHanging, TestReadFileContent_LongLineIsTruncatedNotFatal.

# is_file() follows symlinks and checks S_ISREG, so this also
# excludes FIFOs/sockets/devices -- opening one for reading can
# block indefinitely (e.g. a FIFO with no writer connected).
if not entry.is_file():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed in 07e4d62, with the classification made explicit in 0e303ee.

A walk entry now resolves to one of three outcomes — GREP / SKIP / UNREADABLE — via _classify_walk_entry. A broken symlink is UNREADABLE, so it increments skipped and appears in the trailer. A FIFO stays a silent SKIP: it isn't a read failure, and not blocking on it is the point.

shell.py:43-90 and shell.py:302-321. Tests: test_grep_content_counts_broken_symlink_as_unreadable, test_classify_walk_entry_covers_each_outcome, test_grep_content_does_not_count_fifo_as_unreadable.

# Skip entries whose symlink-resolved target escapes the
# root being searched, so a symlink can't be used to read
# files outside the requested directory.
safe_entry = _validate_path(entry, allowed_root)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and stricter than the nearby Copilot finding: validating allowed_root once let a symlinked entry resolve outside the directory actually being searched. The bound should be the searched root, not the allowed root.

Each entry is now resolved and checked with is_relative_to(root) before it is opened — 07e4d62, shell.py:86-90. Go's equivalent is classifyWalkEntry in go/adk/pkg/tools/grep.go.

Tests: test_grep_content_recursive_skips_symlinks_that_escape_root and ..._escape_the_searched_directory.

Comment thread go/adk/pkg/tools/shell.go Outdated
for scanner.Scan() {
if line := scanner.Text(); re.MatchString(line) {
if len(line) > 2000 {
line = line[:2000] + "..."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Go string slicing is byte-indexed, so s[:n] could split a multi-byte sequence and leave a U+FFFD. It also silently made the limit mean bytes — so the same line truncated at a different point than in the Python runtime (whose str slicing is per-code-point) and than the tool description promises, which says "characters".

Replaced with truncateRunes, which ranges the string (one iteration per rune, with i the byte index where that rune starts) and cuts on a rune boundary. 07e4d62, shell.go:82-94. TestTruncateRunes.

Comment thread go/adk/pkg/tools/shell.go Outdated

info, err := os.Stat(path)
if err != nil {
return "", err

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — ListDirContent returned the bare os.ReadDir error while everything around it wrapped, so the caller lost the path. Now fmt.Errorf("failed to read directory %q: %w", path, err). 07e4d62, shell.go:211-214.

}

sessionRoot, err := filepath.Abs(sessionPath)
sessionRoot, err := filepath.EvalSymlinks(sessionPath)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the dedup mattered less than what it exposed: the three copies had already drifted from each other.

They now share resolveSandboxedPath, parameterized by a pathPolicy value, so every difference is data instead of a divergent copy:

readPolicy  = pathPolicy{filepath.EvalSymlinks, true, "the allowed roots"}
editPolicy  = pathPolicy{filepath.EvalSymlinks, false, "the writable session directory"}
writePolicy = pathPolicy{resolvePathWithExistingParents, false, "the writable session directory"}

Read is the only one permitted outside the session directory; write is the only one that resolves through not-yet-existing parents. d5fb009, skills.go:338-420.

# separately-nullable attributes) keeps "both present or both
# absent" a structural guarantee instead of a convention the two
# attributes have to be kept in sync by hand.
self._file_search_tools: list[BaseTool] = (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the Go path and the two already match — Go registers bash unconditionally too. The if fileSearchEnabled next to it only appends a hint to bash's description; the tool itself is constructed outside that branch. That was true at e3f9c76 as well, so this is a clarification rather than a change.

The gate is deliberately narrow: list_files/grep_file grant filesystem visibility, whereas bash already implies it — gating bash on this flag would change unrelated existing behavior.

Since the code read ambiguously enough to prompt the question, both runtimes now say so at the gate — "Note this gate is theirs alone -- bash below is always registered." — at skills.go:234-236 and skills_toolset.py:56-58.

Comment thread go/adk/pkg/tools/skills.go Outdated
return fmt.Sprintf("Error searching %s: %v", strings.TrimSpace(in.Path), err), nil
}

content, err := skillruntime.GrepContent(path, in.Pattern, in.Recursive, in.IgnoreCase)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half fixed, half a deliberate difference — worth separating the two.

Fixed: GrepContent now takes a context.Context and checks it once per walk entry (not per line — a single file's scan is bounded by its size, the entry count isn't), so a recursive search is abortable by whatever deadline the caller already set. d5fb009, grep.go:95 and :140.

Deliberate: no fixed 30s cap on top of that. Python's cap exists for a hazard Go doesn't have — Python's re backtracks, so an adversarial pattern can hang on a single line. Go uses RE2: linear-time, no backtracking. That leaves only the walk unbounded, and its cost scales with the tree the deployment itself provisioned, so a fixed cap would break large legitimate searches without closing a distinct risk.

The reasoning is recorded in the comment at skills.go:283-296 so it doesn't get rediscovered later as an oversight.

// Also registered (separately, for `kagent env` CLI discoverability only,
// not read here) as KagentEnableFileSearchTools in go/core/pkg/env/kagent.go
// -- keep both string literals in sync if this name ever changes.
const enableFileSearchToolsEnv = "KAGENT_ENABLE_FILE_SEARCH_TOOLS"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They shouldn't share one, because they aren't quite the same thing — but you're right that an unenforced duplicate literal is a trap, so it's now pinned by a test rather than by trust.

The go/core/pkg/env entry exists only for kagent env CLI discoverability; it is never read to decide the gate. The ADK reads the var directly, and deliberately parses it differently: BoolVar.Get() uses strconv.ParseBool (var.go:186), which rejects e.g. "tRue", while fileSearchToolsEnabled strips-and-lowercases into ("1","t","true") to exactly match Python's file_search_tools_enabled(). Routing the gate through the registry would silently change which literal values enable the tools, and split Go from Python.

So instead: TestEnableFileSearchToolsEnvMatchesRegistry asserts the two literals are equal, and each site carries a comment naming the other. skills.go:15-25, core/pkg/env/kagent.go:83-87, skills_test.go:25.

@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale because of no activity in the last 15 days. It will be closed in the next 5 days unless it is tagged "no stalebot" or other activity occurs.

@github-actions github-actions Bot added the stale This issue or PR has become stale label Sep 16, 2026

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI-generated review.

I found three reproducible issues at commit b4f76af0:

  1. Python’s regex timeout cannot prevent the runtime from hanging. file_tools.py:262
    Python’s regex engine holds the interpreter lock during catastrophic backtracking, blocking the event loop too. Searching (a+)+$ against 32 as followed by ! blocked both a 50 ms timeout and an independent heartbeat until my watchdog killed it after 10 seconds. The existing test uses sleep, which releases that lock. Use a terminable subprocess or an engine with enforceable matching limits.

  2. An entry’s permission error discards successful matches. shell.py:302
    A directory with mode 0444 allows listing filenames but prevents inspecting them. entry.is_file() raises PermissionError outside the per-entry handler, aborting the search and losing earlier sibling matches. Classify stat/resolve failures as unreadable entries and continue.

  3. Python silently changes regex patterns. file_tools.py:248
    .strip() removes meaningful whitespace: "foo " incorrectly matches "foobar". Preserve the original pattern when validating and executing it.

Go tools tests passed. All 54 selected Python tests passed after correcting a local interpreter-path issue. The three additional reproduction tests fail as described. No live-cluster validation was run.

_classify_walk_entry called Path.is_file() unguarded. That swallows ENOENT,
ENOTDIR, EBADF and ELOOP, but raises on EACCES -- so a stat failure escaped
the classifier and propagated out of grep_content, discarding every match
already found elsewhere in the tree.

The reachable case is a directory that is readable but not searchable (mode
0o444): os.walk lists its children fine, then stat on each child is denied.
The suite's existing permission tests use mode 0o000, where os.walk itself
fails and the error is already handled by the walk_errors path, so they
could not catch this.

Classify any OSError from those stats as UNREADABLE instead, matching the Go
runtime, whose classifyWalkEntry already maps every EvalSymlinks/Stat failure
to walkEntryUnreadable. The caller counts UNREADABLE and keeps walking.

Also corrects the docstring, which claimed this divergence was "not reachable
through any case we could construct". EACCES reaches it trivially. The
remaining divergence is narrower and is now described accurately.

Reported by EItanya in review of kagent-dev#2267.

Signed-off-by: brandonkeung <[email protected]>
@brandonkeung

brandonkeung commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI-generated review.

I found three reproducible issues at commit b4f76af0:

  1. Python’s regex timeout cannot prevent the runtime from hanging. file_tools.py:262
    Python’s regex engine holds the interpreter lock during catastrophic backtracking, blocking the event loop too. Searching (a+)+$ against 32 as followed by ! blocked both a 50 ms timeout and an independent heartbeat until my watchdog killed it after 10 seconds. The existing test uses sleep, which releases that lock. Use a terminable subprocess or an engine with enforceable matching limits.
  2. An entry’s permission error discards successful matches. shell.py:302
    A directory with mode 0444 allows listing filenames but prevents inspecting them. entry.is_file() raises PermissionError outside the per-entry handler, aborting the search and losing earlier sibling matches. Classify stat/resolve failures as unreadable entries and continue.
  3. Python silently changes regex patterns. file_tools.py:248
    .strip() removes meaningful whitespace: "foo " incorrectly matches "foobar". Preserve the original pattern when validating and executing it.

Go tools tests passed. All 54 selected Python tests passed after correcting a local interpreter-path issue. The three additional reproduction tests fail as described. No live-cluster validation was run.

Thanks — all three reproduced, and all three are fixed.

1. The regex timeout couldn't fire. Confirmed exactly as described. re holds the
GIL while matching, so the match froze every thread including the event loop, and
asyncio.wait_for never got scheduled to fire. My repro: a 50ms heartbeat ticked twice,
then went silent the instant (a+)+$ dispatched; the 0.5s timeout never fired and the
process needed a hard kill at 8s. You were right about the test too — it faked slowness
with time.sleep, which releases the GIL, so it only ever proved that wait_for works
when the worker cooperates.

Fixed in 028a5fa by moving the match to a worker process (_run_with_deadline,
file_tools.py:67). A process has its own GIL, so the loop stays responsive and the
deadline is honored — and unlike a thread the runaway can be killed, which it is:
shutdown() won't stop a worker mid-match, so _kill_workers (file_tools.py:37)
terminates it. Verified in-cluster: the same pattern now returns the proper error at 33s
(30s + overhead), /proc shows no leaked worker, and the next grep on that agent succeeds.

Two consequences I'd rather state than leave in the diff:

  • grep_file costs ~100ms more per call (measured 113/100/100ms vs ~0ms warm). A
    shared pool avoids that, but then a timeout must recycle shared state — killing workers
    belonging to unrelated in-flight greps and surfacing BrokenProcessPool to callers that
    did nothing wrong. At ~100ms inside a multi-second model turn I didn't think that was
    worth buying. Happy to revisit if you disagree.
  • Mock-based tests for grep_file no longer work. A MagicMock can't be pickled to a
    worker, and the child imports the real module regardless. The two tests that patched
    grep_content are now real-file tests — stronger, but worth knowing before adding more.

Tests: test_grep_file_tool_times_out_on_catastrophic_backtracking runs a genuinely
pathological match and asserts the event loop kept ticking.
test_grep_work_runs_in_a_separate_process is a fast tripwire, because the behavioral
test hangs rather than fails if the offload regresses to a thread, and there's no
pytest-timeout plugin here to cut it short.

2. A permission error discarded accumulated matches. Confirmed. Path.is_file()
swallows ENOENT/ENOTDIR/EBADF/ELOOP but raises on EACCES, and the classifier call sat
outside the try guarding the per-file read — so one unstattable entry aborted the search
and threw away matches already found in sibling directories.

Worth noting why the existing permission tests missed it: they use mode 0o000, where
os.walk itself fails and the error is already handled by the walk_errors path. Your
0o444 is the case that slips through — readable, so os.walk lists the children, but not
searchable, so stat on each child is denied.

Fixed in 0b0bfe8 (shell.py:71-85): stat failures are classified UNREADABLE, so the caller
counts them and keeps walking — matching Go's classifyWalkEntry, which already maps every
EvalSymlinks/Stat failure to walkEntryUnreadable. Tests:
test_classify_walk_entry_counts_a_permission_error_as_unreadable and
test_grep_content_keeps_sibling_matches_when_an_entry_cannot_be_statted. I also corrected
the docstring, which claimed this divergence was "not reachable through any case we could
construct" — chmod 0444 reaches it trivially.

3. The pattern was silently rewritten. Confirmed: "foo " became "foo", which matches
foobar — text the pattern excludes. Fixed in 028a5fa (file_tools.py:317,322): trim only to
decide emptiness, search with what was asked for. That's the split skills.go already makes,
so the runtimes no longer disagree on identical input. Test:
test_grep_file_tool_passes_the_pattern_through_verbatim uses a file containing both
foobar and foo bar and asserts only line 2 matches.

Cross-runtime check. Since two of these were Go/Python divergences, I ran the same two
scenarios against the Go runtime through the full stack (AgentInstance → Substrate actor →
Go ADK) and got byte-identical output:

fixA/aaa_ok/match.txt:1:foo readable

(1 entries could not be read)

fixC/data.txt:2:foo bar

@EItanya

Two defects in GrepFileTool, combined because the test for the second
depends on the harness the first one forced.

1. The 30s timeout could not fire.

   The match ran in a ThreadPoolExecutor, but `re` does not release the GIL
   while matching. A catastrophic pattern therefore froze every thread in the
   interpreter, the event loop included, so asyncio.wait_for never got
   scheduled to time anything out. Reproduced with `(a+)+$` against 32 "a"s
   plus "!": a 0.5s timeout did not fire and an independent 50ms heartbeat
   stopped dead until the process was killed. The tool's error message
   ("pattern took too long to match") was therefore unreachable in exactly
   the case it described.

   The match now runs in a single-use worker process, which has its own GIL,
   so the loop stays responsive and the deadline is honored. Unlike a thread,
   the runaway can also be killed, and it is -- otherwise the deadline would
   bound only the caller's wait while the worker kept burning a core.

   A per-call pool rather than a shared one: a shared pool saves ~100ms of
   spawn per call but forces a timeout to recycle shared state, killing
   workers belonging to unrelated greps and surfacing BrokenProcessPool to
   callers that did nothing wrong. At ~100ms inside a multi-second model turn
   that trade is not worth making.

2. The pattern was stripped before use.

   args.get("pattern", "").strip() turned "foo " into "foo", which matches
   "foobar" -- text the caller's pattern excludes. Whitespace is meaningful
   in a regex. Trim only to decide emptiness and search with what was asked
   for, which is the split skills.go already makes, so the two runtimes no
   longer disagree on identical input.

The existing timeout test faked slowness with time.sleep, which *releases*
the GIL, so it passed while the real hazard went unguarded. It now runs a
genuinely pathological match and asserts the event loop kept ticking. A
second, fast test asserts the work leaves this process, because the
behavioral test hangs rather than fails if the offload regresses to a thread
and there is no pytest-timeout plugin here to cut it short.

Tests that patched grep_content were rewritten against real files: a
MagicMock cannot be pickled to a worker process, and the child imports the
real module regardless.

Reported by EItanya in review of kagent-dev#2267.

Signed-off-by: brandonkeung <[email protected]>
@github-actions github-actions Bot removed the stale This issue or PR has become stale label Sep 23, 2026
@EItanya
EItanya enabled auto-merge September 23, 2026 15:47
@EItanya
EItanya added this pull request to the merge queue Sep 23, 2026
Merged via the queue into kagent-dev:main with commit a42400e Sep 23, 2026
28 checks passed
alexdarbyshire pushed a commit to alexdarbyshire/kagent that referenced this pull request Oct 5, 2026
## Summary
- Adds native, in-process `list_files` and `grep_file` tools to both the
Go (`go/adk/pkg/skills`, `go/adk/pkg/tools`) and Python
(`kagent-skills`, `kagent-adk`) agent runtimes, alongside the existing
`read_file`/`write_file`/`edit_file`/`bash` skills tools.
- Gives agents safe, non-privileged file visibility without depending on
`bash`, which some deployments disable for privilege/security reasons.
- Hardened across several review passes; fixes along the way:
- Go: a symlink-resolution inconsistency in
`resolveReadPath`/`resolveWritePath`/`resolveEditPath` could reject
valid paths under a symlinked session root.
- Go: `NewSkillsTools` failed entirely (dropping all tools) when the
bash command executor couldn't be constructed, instead of just omitting
bash.
- Go: `filepath.WalkDir` root resolution fixed so an unresolved symlink
root is actually recursed into; an err-shadowing bug that silently
truncated results on a `WalkDir` failure fixed.
- Both: `grep_file` no longer hangs indefinitely on a FIFO (or other
non-regular file) with no writer connected, in both the recursive walk
and single-target paths.
- Both: matched lines are capped to 2000 chars (matching `read_file`'s
existing convention) — previously unbounded in Python, and could fail
the whole search past 1MB in Go.
- Python: `GrepFileTool` now uses a dedicated thread pool instead of the
shared default pool, since a hung regex match can't be forcibly killed
and would otherwise starve unrelated work.
- Both: a single unreadable file or subdirectory is now a skip rather
than an abort of the whole search, so one bad entry doesn't discard
matches already found elsewhere in the tree — annotated as `"no matches
found (N entries could not be read)"` so a systemic failure isn't
indistinguishable from a genuinely empty search.
- Both: a fully unreadable search root now surfaces a real error instead
of a misleadingly confident empty result.
- Per maintainer feedback, `list_files`/`grep_file` are now **disabled
by default** and gated behind `KAGENT_ENABLE_FILE_SEARCH_TOOLS` (set on
the Agent's `env` to opt in) — mirroring how `bash` is already
effectively opt-in. `read_file`/`write_file`/`edit_file`/`bash`/`skills`
are unaffected.


## Test plan
- [x] Go: `TestGrepContent` (`go/adk/pkg/skills/shell_test.go`) —
matches, recursion, symlink escape/resolution, FIFO hang safety,
unreadable file/subdirectory/root handling, output truncation — and
`TestListFilesAndGrepFileTools_RunThroughADK`,
`TestNewSkillsTools_OmitsBashWithoutSRTSettings`
(`go/adk/pkg/tools/skills_test.go`)
- [x] Python: unit tests in `kagent-skills`/`kagent-adk` covering the
same scenarios (path traversal, recursive/ignore-case, symlink escape,
FIFO hang safety, unreadable file/subdirectory/root handling,
truncation, `GrepFileTool` timeout behavior)
- [x] All new regression tests verified to fail against the pre-fix
code, confirming they exercise the actual bug
- [x] Verified end-to-end in a live kind cluster via the browser UI for
both runtimes (dedicated Go- and Python-runtime test agents), including
the FIFO-hang scenario and the unreadable-subdirectory/root scenarios,
each invoking `list_files`/`grep_file` by name and returning correct
output
- [x] Go/Python unit tests for the `KAGENT_ENABLE_FILE_SEARCH_TOOLS`
flag's default-off/opt-in behavior (`skills_test.go`,
`test_skill_execution.py`, new `test_skills_plugin.py`)
- [x] Verified end-to-end on a live kind cluster via A2A and the browser
UI: dedicated Go- and Python-runtime test agents, with and without
`KAGENT_ENABLE_FILE_SEARCH_TOOLS` set, confirming correct tool
registration and that `list_files`/`grep_file` execute correctly when
enabled

### Known gaps

- **No shared Go/Python conformance fixture.** 6 of the 10 review
findings were
runtime drift between the two implementations. That parity is currently
held by
prose comments naming the other runtime's identifiers, which is weaker
than it
  should be for behavior that must match. Tracked in #<issue>.
- **`EditFileContent` ambiguity threshold diverges** (pre-existing,
untouched by
this PR): Go errors only when the trimmed `old_string` is under 5
characters;
  Python always errors on multiple matches without `replace_all`.

---------

Signed-off-by: brandonkeung <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants