Skip to content

fix(tools): honor brace globs and path patterns in grep include - #6325

Closed
BlueX888 wants to merge 4 commits into
OpenHands:mainfrom
BlueX888:fix/grep-include-brace-glob
Closed

BlueX888 wants to merge 4 commits into
OpenHands:mainfrom
BlueX888:fix/grep-include-brace-glob

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

HUMAN:


AGENT:

Why

The grep tool documents include="*.{ts,tsx}" as the canonical filter example (openhands-tools/openhands/tools/grep/definition.py), but that call never returned a match. ripgrep receives -g <include> and selects the right files; _finalize_matches then re-applies _path_matches_filters, whose include check was fnmatch.fnmatch(filename, include_pattern) on the basename only:

  • fnmatch has no brace expansion, so every match for *.{ts,tsx} was dropped: "No files found ... (filtered by '*.{ts,tsx}')".
  • Patterns containing a path separator (sub/*.ts, **/*.py) were matched against the basename too, so they were silently empty.
  • The system grep and Python backends rely on the same post-filter and were wrong the same way.

Summary

  • Expand {a,b} alternatives (including nested groups) and translate each glob to a segment-aware regex: */? stay within one segment, a whole-segment ** spans zero or more, character classes are supported.
  • Globs containing / now match the path relative to the search root; globs without one match the file name — the same split ripgrep uses. ripgrep now runs with cwd=search_path so its -g resolves path-bearing globs against the search root too, and all three backends agree.
  • Merge adjacent **/ segments before compiling. Without this, ("**/" * 20) + "*.zzz" compiled to nested (?:[^/]*/)* repetitions and took ~105 s on a 14-segment path; it now matches in ~0.1 ms.

Issue Number

Part of #6348 — this PR fixes the brace/path include case (*.{ts,tsx}, sub/*.ts) from the edge-case bug collection.

How to Test

Fixture with a.ts, b.tsx, and files whose content matches the pattern:

from openhands.tools.grep import GrepExecutor, GrepAction

obs = GrepExecutor(working_dir=tmp).__call__(
    GrepAction(pattern="print", path=str(tmp), include="*.{ts,tsx}")
)
assert sorted(Path(p).name for p in obs.matches) == ["a.ts", "b.tsx"]  # before: []

Included tests: tests/tools/grep/test_grep_executor.py (ripgrep, system grep and Python backends for brace and path patterns; pathological **/ timing), tests/tools/grep/test_consistency.py (all backends return the same files), tests/tools/grep/test_grep_tool.py (tool-level brace include).

Before/after evidence: on 07e8c26 the eight new assertions fail with empty match lists (assert 0 == 2); with this branch tests/tools/grep is 59 passed. Baseline vs candidate across tests/tools: 898 passed, 9 failures identical on baseline (browser-use and task MCP, unrelated). ruff check / ruff format --check clean. An independent fuzz comparison against rg -g over 1,643 patterns found no semantic difference for the supported syntax.

Video/Screenshots

Not visual — the commands and outputs above are the evidence.

Design Doc

Not needed: the change is contained to the include matcher in one file plus tests.

Notes on scope and behavior:

  • Ripgrep-only syntax outside the documented include contract (! negation, backslash-escaped literals such as \[, leading / anchors) keeps its previous behavior and is not addressed here.
  • Include matching is now case-sensitive on Windows as well, to match ripgrep (fnmatch was case-insensitive there). *.ts no longer matches X.TS; ripgrep treats that pattern as case-sensitive on all platforms.
  • The SDK's terminal-command grep fallback (openhands-sdk/.../agent/utils.py::_GREP_FALLBACK_SCRIPT) is a separate embedded implementation and still uses fnmatch on the basename; this PR only covers the grep tool's include filter.
  • The backtracking fix covers adjacent **/ runs (the reported trigger). Non-adjacent adversarial sequences such as repeated "**/a/" segments can still backtrack in theory; closing that would need a different matcher and is left out of this focused fix.

BlueX888 and others added 2 commits October 30, 2026 01:08
The grep tool advertises include="*.{ts,tsx}", but such calls returned no
matches: ripgrep's -g selected the right files, then _finalize_matches
re-applied _path_matches_filters, whose include check was
fnmatch.fnmatch(basename, pattern). fnmatch has no brace expansion, so
every match was dropped. The same post-filter is the only include filter
for the system grep and Python backends, and it also silently rejected
any include containing a path separator (sub/*.ts, **/*.py).

Expand {a,b} alternatives (nested groups included) and translate each
glob to a segment-aware regex: `*` and `?` stay within one path segment
while a whole-segment `**` spans zero or more. Globs with a slash match
the path relative to the search root; globs without one match the file
name, mirroring ripgrep. ripgrep now runs from the search root because it
resolves path-bearing globs against its working directory, so all three
backends agree on brace and path includes.

Co-authored-by: openhands <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
Adjacent globstar segments such as "**/" repeated compile to nested
(?:[^/]*/)* repetitions. A failing match then has to enumerate every
way to distribute path segments across those repetitions: a 20x "**/"
include took 2.2s / 15.4s / 87s for 10 / 12 / 14 segment paths.

Merge adjacent `**/` segments before compiling. `**/**/` matches the
same paths as `**/`, so normal-pattern semantics are unchanged, while
the pathological include now matches in microseconds.

Co-authored-by: openhands <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  This PR no longer adds or changes files under `.pr/`.

@VascoSch92

Copy link
Copy Markdown
Member

@BlueX888 Could you fill the HUMAN section?

@VascoSch92

Copy link
Copy Markdown
Member

Hey @BlueX888, following up on my comment from yesterday: the description check is still failing. It needs a short note in your own words under HUMAN:, and an ## Issue Number section. Part of #6348 works there (please not Fixes, since that issue is a tracker). Once that's in I'll review the code.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Scope

In scope: this repository owns the grep tool (openhands-tools), and the linked tracker #6348 (ready-for-dev) pins exactly this defect — "*.{ts,tsx} is filtered with fnmatch, which has no brace expansion". The change is contained to the include matcher in openhands-tools/openhands/tools/grep/impl.py plus tests. No product/architecture decision needed.

Verification on the current head c4521ada

I re-ran the PR's own scenarios against real ripgrep 15.1.0 (local reproduction; CI runners do not install rg, so the ripgrep-marked tests are skipped there — the tools-tests job installs only uv deps):

  • The brace/path matcher itself is correct. The head's Python backend and the head's ripgrep backend (now run with cwd=search_path) return identical file sets for 18 include patterns I cross-checked (*.{ts,tsx}, sub/*.ts, **/*.ts, src/*.{py,md}, level1/**/*.ts, classes, ?, nested braces). Hidden-directory exclusion is preserved (_path_matches_filters runs after the include check and still rejects dot-directories), and the **/-run collapse removes the catastrophic backtracking on the ("**/"*20)+"*.ts" case (~0.02–0.12 ms on a 14-segment path).
  • The Python and system-grep backends agree on the new semantics; path-bearing globs now match relative to the search root as documented.

Material finding: the head regresses two fixes already merged to main

The branch last merged main at e12c9615 (Nov 3). Since then, main gained two fixes to the same _execute_with_ripgrep method, and the current head dropped both:

  1. -e before the pattern (#6537, merged Nov 7 11:59Z): without it, ripgrep parses a leading-dash pattern as CLI flags. Demonstrated on this head with real rg:
    • include=None, pattern="-foo" → head returns "No files found" (main returns flags.txt).
    • pattern="--files" → head lists every file in the tree (main returns []). That is user-controlled flag injection into the rg argv, previously fixed and now reintroduced.
  2. Python-search fallback when ripgrep exits ≠ 0/1 (#6593, merged Nov 7 18:17Z): a pattern re.compile accepts but rust-regex rejects (e.g. (?<=a)b) makes rg exit 2; the head silently reports "No files found" while main logs a warning and falls back to the Python search, which finds the match.

Both fixes' regression tests (test_pattern_starting_with_dash, test_ripgrep_pattern_cannot_inject_flags) exist on main and are absent from the head's test_grep_executor.py. A plain three-way merge of main into this branch auto-restores both hunks (I verified in a throwaway worktree: the merged file contains -e, the fallback, and the new include matcher simultaneously), but the head as it stands is behind main — and a squash merge of this head would lose both fixes outright. Please re-merge main (and keep the two hunks) before this is merged.

Other checks

  • Validate PR description is failing: the HUMAN: section still holds the placeholder. This is already known to the author (VascoSch92's comments); it gates the merge but is not a code defect.
  • PR body's own scope notes are accurate: the SDK terminal grep fallback in openhands-sdk is a separate implementation and is correctly left untouched; the Windows case-sensitivity change matches ripgrep and is covered by the passing windows-tests job.
  • All other checks on c4521ada pass, including tools-tests, check, pre-commit, and security.

With the main re-merge in place, I see no remaining correctness, security, or compatibility defect in the include-matcher change itself.

🔄 CHANGES REQUESTED

@BlueX888

BlueX888 commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Merged main into the branch (head 0d5e0569). Both #6537 (-e before the pattern) and #6593 (Python fallback when ripgrep exits with a code other than 0/1) are restored alongside the include matcher, and their regression tests are back. The only remaining failing check is Validate PR description; the HUMAN: note is reserved for the account owner and still needs to be written by them.

@VascoSch92

Copy link
Copy Markdown
Member

Hey @BlueX888, thank you for the work on this and for keeping the branch up to date with main. The *.{ts,tsx} case from the tracker is now fixed on main through a smaller change in b19a281, which reuses the glob tool's brace expansion. That leaves this branch in conflict, so I'm closing it.

That change doesn't cover the part where your PR went further: includes with a path, like sub/*.ts or **/*.ts, still return nothing on main. If you'd like to pick that up, I'd be glad to review a smaller PR focused on just that. It will need a short note from the account owner under HUMAN:, which is what held this one up. Thanks again!

@BlueX888

BlueX888 commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the clear pointer. I opened #6680 as the smaller follow-up: it keeps brace expansion from #6636 and only adds path-bearing include support (sub/*.ts, **/*.ts, sub/**/*.ts, and brace path patterns), with a cross-backend regression test. It is in draft pending the HUMAN: note from the account owner, same as this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants