Skip to content

fix(file-ops): make rg/grep search error guard reachable and preserve… - #39858

Merged
teknium1 merged 1 commit into
mainfrom
fix/rg-grep-error-guard-reachable
Jun 6, 2026
Merged

fix(file-ops): make rg/grep search error guard reachable and preserve…#39858
teknium1 merged 1 commit into
mainfrom
fix/rg-grep-error-guard-reachable

Conversation

@teknium1

@teknium1 teknium1 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

… partial matches

The error guard in _search_with_rg/_search_with_grep was unreachable and, if it had fired, would have discarded valid results.

Two root causes:

  1. Unreachable. Both methods pipe the search through | head with no pipefail, so the pipeline reported head's exit code (0), masking rg/grep's error code (2). The guard never fired. Worse, because _exec merges stderr into stdout (stderr=subprocess.STDOUT), the error text was then parsed as bogus match lines instead of being surfaced — the user got garbage matches with no indication the search failed.

  2. Latent results-dropping. The original not result.stdout.strip() check was always False on error (error text lives in stdout), and the hasattr(result, 'stderr') branch was dead code (ExecuteResult has no stderr field). A naive broadening to exit_code == 2 would have nuked real matches whenever rg/grep also hit a non-fatal error (e.g. one unreadable file in a tree that otherwise matched), which both tools signal with exit 2.

Fix:

  • Prefix the piped command with set -o pipefail so rg/grep's real exit status propagates. rg exits 0 on a truncating head; grep exits 141 (SIGPIPE), so the strict == 2 guard ignores truncated-success.
  • Add _split_tool_diagnostics() to separate tool diagnostics from match output by tool prefix and output shape. Diagnostics never become matches; on a hard error they are the message to surface.
  • Only surface an error when exit==2 AND no usable match payload remains, so partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the real local backend (hard error surfaced, partial error keeps matches, truncation no false error, files_only/count exclude diagnostics) plus unit coverage for the splitter.

Supersedes #39710.

What does this PR do?

Related Issue

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

How to Test

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform:

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

For New Skills

  • This skill is broadly useful to most users (if bundled) — see Contributing Guide
  • SKILL.md follows the standard format (frontmatter, trigger conditions, steps, pitfalls)
  • No external dependencies that aren't already available (prefer stdlib, curl, existing Hermes tools)
  • I've tested the skill end-to-end: hermes --toolsets skills -q "Use the X skill to do Y"

Screenshots / Logs

… partial matches

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes #39710.
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: fix/rg-grep-error-guard-reachable vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9867 on HEAD, 9868 on base (✅ -1)

🆕 New issues (1):

Rule Count
unresolved-import 1
First entries
tests/tools/test_search_error_guard.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues (1):

Rule Count
unresolved-attribute 1
First entries
tools/file_operations.py:2146: [unresolved-attribute] unresolved-attribute: Object of type `~AlwaysFalsy` has no attribute `strip`

Unchanged: 5115 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets tool/file File tools (read, write, patch, search) labels Jun 5, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes #39710 (same unreachable rg/grep error guard in tools/file_operations.py) with a more robust pipefail + diagnostics-splitting approach that preserves partial matches. Flagging #39710 for closure consideration.

@teknium1
teknium1 merged commit ea266f4 into main Jun 6, 2026
23 checks passed
@teknium1
teknium1 deleted the fix/rg-grep-error-guard-reachable branch June 6, 2026 00:44
changman pushed a commit to changman/hermes-agent that referenced this pull request Jun 10, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
alt-glitch pushed a commit that referenced this pull request Jun 14, 2026
… partial matches (#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes #39710.
kossteg pushed a commit to kossteg/hermes-agent that referenced this pull request Jun 16, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
T02200059 pushed a commit to T02200059/hermes-agent that referenced this pull request Jun 18, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
donbowman pushed a commit to donbowman/hermes-agent that referenced this pull request Jul 13, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes NousResearch#39710.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
… partial matches (NousResearch#39858)

The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

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

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists tool/file File tools (read, write, patch, search) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants