fix(sdk): timeout python grep fallback (#1937)

Stopgap whilst waiting for #1935

---

> [!WARNING]
> **Behavior change to the `grep` tool.** This PR alters grep semantics
in two ways:
>
> 1. **The Python fallback can no longer hang indefinitely.** Previously
only the ripgrep path enforced a 30s timeout; the pure-Python fallback
(used when `rg` is not on `PATH`) walked the tree unbounded. It now
aborts after `DEFAULT_GREP_TIMEOUT` (30s) and returns the matches
collected so far plus a partial-error message.
> 2. **Partial matches are now surfaced instead of discarded.**
Previously any backend error caused the tool to return only the error
string with `status="error"`, throwing away any matches already found.
The tool now returns those partial matches alongside the error
(`status="error"`, content includes a `Partial matches:` section). The
clean, fully-successful path is unchanged (`status="success"`, identical
content).


`FilesystemBackend._python_search` had no timeout — on large repos, if
ripgrep timed out or wasn't installed, the Python fallback would walk
the entire file tree via `rglob("*")` indefinitely, blocking the calling
thread forever. This was observed in production: a subagent's grep tool
call hung permanently, stalling an entire ACP session.
This commit is contained in:
Mason Daugherty
2026-06-02 18:18:47 -04:00
committed by GitHub
parent 4c28760abb
commit 665a18e7fe
7 changed files with 208 additions and 29 deletions
@@ -9,6 +9,7 @@ import os
import re
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
@@ -16,6 +17,7 @@ import wcmatch.glob as wcglob
from deepagents._api.deprecation import warn_deprecated
from deepagents.backends.protocol import (
DEFAULT_GREP_TIMEOUT,
FILE_NOT_FOUND,
INVALID_PATH,
IS_DIRECTORY,
@@ -629,12 +631,12 @@ class FilesystemBackend(BackendProtocol):
cmd,
capture_output=True,
text=True,
timeout=30,
timeout=DEFAULT_GREP_TIMEOUT,
check=False,
cwd=rg_cwd,
)
except subprocess.TimeoutExpired:
logger.warning("ripgrep timed out after 30s; using Python grep fallback")
logger.warning("ripgrep timed out after %ds; using Python grep fallback", DEFAULT_GREP_TIMEOUT)
return None
except (FileNotFoundError, PermissionError, NotADirectoryError) as e:
# `rg` resolved at cache time but failed at exec — treat as a
@@ -713,25 +715,35 @@ class FilesystemBackend(BackendProtocol):
return results
def _python_search(self, pattern: str, base_full: Path, include_glob: str | None) -> tuple[dict[str, list[tuple[int, str]]], str | None]: # noqa: C901, PLR0912
def _python_search( # noqa: C901, PLR0912
self,
pattern: str,
base_full: Path,
include_glob: str | None,
*,
timeout: int = DEFAULT_GREP_TIMEOUT,
) -> tuple[dict[str, list[tuple[int, str]]], str | None]:
"""Fallback search using Python when ripgrep is unavailable.
Recursively searches files, respecting `max_file_size_bytes` limit.
Recursively searches files, respecting `max_file_size_bytes` limit
and a wall-clock timeout.
Args:
pattern: Escaped regex pattern (from re.escape) for literal search.
base_full: Resolved base path to search in.
include_glob: Optional glob pattern to filter files by name.
timeout: Maximum wall-clock seconds before the search is aborted.
Returns:
`results` contains every match found before iteration completed.
`partial_error` is `None` on a clean walk, otherwise a
human-readable message indicating the walk aborted early
(e.g., a directory entry was removed mid-walk); callers
human-readable message indicating the walk was incomplete:
either the wall-clock `timeout` elapsed or the walk aborted
early (e.g., a directory entry was removed mid-walk). Callers
should treat such results as incomplete.
"""
# Compile escaped pattern once for efficiency (used in loop)
deadline = time.monotonic() + timeout
regex = re.compile(pattern)
results: dict[str, list[tuple[int, str]]] = {}
@@ -739,6 +751,14 @@ class FilesystemBackend(BackendProtocol):
try:
for fp in root.rglob("*"):
if time.monotonic() > deadline:
msg = (
f"Grep of '{base_full}' timed out after {timeout}s "
f"with {len(results)} matching file(s); try a more "
f"specific pattern or a narrower path."
)
logger.warning("%s", msg)
return results, msg
try:
if not fp.is_file():
continue
@@ -31,6 +31,16 @@ r"""File storage format version.
logger = logging.getLogger(__name__)
DEFAULT_GREP_TIMEOUT: Final = 30
"""Default timeout in seconds for one sync grep phase."""
ASYNC_GREP_TIMEOUT: Final = (2 * DEFAULT_GREP_TIMEOUT) + 5
"""Timeout in seconds for the async grep wrapper.
This gives `FilesystemBackend` enough headroom to finish the worst-case sync
path: ripgrep timeout, then Python fallback timeout.
"""
FileOperationError = Literal[
"file_not_found",
"permission_denied",
@@ -462,8 +472,28 @@ class BackendProtocol(abc.ABC): # noqa: B024
path: str | None = None,
glob: str | None = None,
) -> "GrepResult":
"""Async version of `grep`."""
return await asyncio.to_thread(self.grep, pattern, path, glob)
"""Async version of `grep`.
Wraps the sync call with an async timeout as a safety net. The timeout
bounds how long the caller waits; it does not stop the worker thread
created by `asyncio.to_thread`.
"""
try:
return await asyncio.wait_for(
asyncio.to_thread(self.grep, pattern, path, glob),
timeout=ASYNC_GREP_TIMEOUT,
)
except TimeoutError:
logger.warning(
"agrep timed out after %ds (pattern=%r, path=%r, glob=%r)",
ASYNC_GREP_TIMEOUT,
pattern,
path,
glob,
)
return GrepResult(
error=f"Error: grep timed out after {ASYNC_GREP_TIMEOUT}s. Try a more specific pattern or a narrower path.",
)
def glob(self, pattern: str, path: str | None = None) -> "GlobResult":
"""Find files matching a glob pattern.
@@ -43,6 +43,7 @@ from deepagents.backends.protocol import (
FileData as FileData, # Re-export for backwards compatibility
FileInfo,
GrepMatch,
GrepResult,
ReadResult,
SandboxBackendProtocol,
WriteResult,
@@ -164,6 +165,21 @@ def _filter_grep_matches_by_permission(
return [m for m in matches if _check_fs_permission(rules, operation, m.get("path", "")) == "allow"]
def _format_grep_tool_result(
result: GrepResult,
output_mode: Literal["files_with_matches", "content", "count"],
) -> tuple[str, Literal["success", "error"]]:
"""Format a backend grep result for the tool boundary."""
matches = result.matches or []
if result.error and not matches:
return result.error, "error"
formatted = format_grep_matches(matches, output_mode)
if result.error:
return f"{result.error}\n\nPartial matches:\n{formatted}", "error"
return formatted, "success"
def _apply_permissions_to_ls_results(
rules: list[FilesystemPermission],
entries: list[FileInfo],
@@ -1307,7 +1323,7 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
args_schema=GlobSchema,
)
def _create_grep_tool(self) -> BaseTool: # noqa: C901
def _create_grep_tool(self) -> BaseTool:
"""Create the grep tool."""
tool_description = self._custom_tool_descriptions.get("grep") or GREP_TOOL_DESCRIPTION
@@ -1341,21 +1357,17 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
)
resolved_backend = self._get_backend(runtime)
grep_result = resolved_backend.grep(pattern, path=path, glob=glob)
if grep_result.error:
return ToolMessage(
content=grep_result.error,
name="grep",
tool_call_id=runtime.tool_call_id,
status="error",
)
matches = grep_result.matches or []
filtered_matches = _filter_grep_matches_by_permission(self._permissions, matches, operation="read")
formatted = format_grep_matches(filtered_matches, output_mode)
formatted, status = _format_grep_tool_result(
GrepResult(error=grep_result.error, matches=filtered_matches),
output_mode,
)
return ToolMessage(
content=truncate_if_too_long(formatted),
tool_call_id=runtime.tool_call_id,
name="grep",
status="success",
status=status,
)
async def async_grep(
@@ -1388,21 +1400,17 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
)
resolved_backend = self._get_backend(runtime)
grep_result = await resolved_backend.agrep(pattern, path=path, glob=glob)
if grep_result.error:
return ToolMessage(
content=grep_result.error,
name="grep",
tool_call_id=runtime.tool_call_id,
status="error",
)
matches = grep_result.matches or []
filtered_matches = _filter_grep_matches_by_permission(self._permissions, matches, operation="read")
formatted = format_grep_matches(filtered_matches, output_mode)
formatted, status = _format_grep_tool_result(
GrepResult(error=grep_result.error, matches=filtered_matches),
output_mode,
)
return ToolMessage(
content=truncate_if_too_long(formatted),
tool_call_id=runtime.tool_call_id,
name="grep",
status="success",
status=status,
)
return StructuredTool.from_function(
@@ -1003,6 +1003,35 @@ class TestWindowsPathHandling:
assert "\\" not in info["path"], f"Backslash in deep path: {info['path']}"
class TestGrepPythonFallbackTimeout:
"""Tests for the wall-clock timeout on the Python grep fallback."""
def test_python_search_times_out_with_zero_timeout(self, tmp_path: Path) -> None:
"""`_python_search` returns a `timed out` partial error when the deadline is exceeded."""
(tmp_path / "file.txt").write_text("hello")
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
_results, partial_error = be._python_search("hello", tmp_path, None, timeout=0)
assert partial_error is not None
assert "timed out" in partial_error
def test_grep_surfaces_timeout_with_partial_results(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`grep` surfaces the timeout as a partial error while still returning matches found so far."""
(tmp_path / "file.txt").write_text("hello")
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
monkeypatch.setattr(FilesystemBackend, "_ripgrep_search", lambda *_a, **_kw: None)
monkeypatch.setattr(
be,
"_python_search",
lambda *_a, **_kw: ({"/file.txt": [(1, "hello")]}, "Grep of '/' timed out after 0s with 1 matching file(s)"),
)
result = be.grep("hello", path="/")
assert result.error is not None
assert "timed out" in result.error
# Partial matches collected before the timeout are preserved.
assert result.matches
assert result.matches[0]["path"] == "/file.txt"
class TestEditCrlfNormalization:
"""Tests for CRLF normalization in edit(). See #2247."""
@@ -4,13 +4,17 @@ Verifies that unimplemented protocol methods raise NotImplementedError
instead of silently returning None.
"""
import asyncio
import errno
import warnings
from unittest.mock import patch
import pytest
from deepagents.backends.filesystem import _map_exception_to_standard_error
from deepagents.backends.protocol import (
ASYNC_GREP_TIMEOUT,
DEFAULT_GREP_TIMEOUT,
BackendProtocol,
GlobResult,
GrepResult,
@@ -200,6 +204,37 @@ class TestLegacySubclassOverrideRouting:
await sandbox_backend.aexecute("ls")
class TestAgrepTimeout:
"""Tests for `agrep` async timeout safety net."""
def test_agrep_timeout_exceeds_two_sync_grep_phases(self) -> None:
"""`agrep` gives `FilesystemBackend` headroom for `rg` timeout plus fallback timeout."""
assert ASYNC_GREP_TIMEOUT > (2 * DEFAULT_GREP_TIMEOUT)
async def test_agrep_returns_error_on_timeout(self, backend: BareBackend) -> None:
"""`agrep` catches `TimeoutError` and returns `GrepResult` with error."""
seen_timeout = None
async def mock_wait_for(coro, *, timeout): # noqa: ASYNC109
nonlocal seen_timeout
seen_timeout = timeout
coro.close()
raise TimeoutError
with patch.object(asyncio, "wait_for", mock_wait_for):
result = await backend.agrep("pattern", "/path", "*.py")
assert seen_timeout == ASYNC_GREP_TIMEOUT
assert result.error is not None
assert "timed out" in result.error
assert result.matches is None
async def test_agrep_propagates_not_implemented(self, backend: BareBackend) -> None:
"""`NotImplementedError` from `grep` still propagates through the timeout wrapper."""
with pytest.raises(NotImplementedError):
await backend.agrep("pattern")
def _runtime_error_from_eloop_context() -> RuntimeError:
"""Create the Python <=3.12 `Path.resolve()` symlink-loop shape via `__context__`."""
exc = RuntimeError("resolver failed")
@@ -20,6 +20,7 @@ import deepagents.middleware.filesystem as filesystem_middleware
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
from deepagents.backends.protocol import (
ExecuteResponse,
GrepResult,
ReadResult,
SandboxBackendProtocol,
)
@@ -498,6 +499,34 @@ class TestFilesystemMiddleware:
assert "/helper.txt" in result.content
assert "/main.py" not in result.content
def test_grep_partial_error_preserves_matches(self):
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
grep_search_tool = next(tool for tool in middleware.tools if tool.name == "grep")
backend_obj = middleware._get_backend(_runtime())
result_with_partial_matches = GrepResult(
error="Grep timed out after 30s with 1 matching file(s)",
matches=[{"path": "/test.py", "line": 1, "text": "import os"}],
)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "grep", return_value=result_with_partial_matches),
):
result = grep_search_tool.invoke(
{
"pattern": "import",
"output_mode": "content",
"runtime": _runtime(),
}
)
assert result.status == "error"
assert "Grep timed out after 30s" in result.content
assert "Partial matches:" in result.content
assert "/test.py" in result.content
assert "1: import os" in result.content
def test_grep_search_shortterm_content_mode(self):
files = {
"/test.py": FileData(
@@ -9,7 +9,7 @@ from langgraph.store.memory import InMemoryStore
import deepagents.middleware.filesystem as filesystem_middleware
from deepagents.backends import StateBackend, StoreBackend
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
from deepagents.backends.protocol import ExecuteResponse, GrepResult, SandboxBackendProtocol
from deepagents.middleware.filesystem import FileData, FilesystemMiddleware, FilesystemState
@@ -348,6 +348,34 @@ class TestFilesystemMiddlewareAsync:
assert "/helper.txt" in result.content
assert "/main.py" not in result.content
async def test_agrep_partial_error_preserves_matches(self):
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
grep_search_tool = next(tool for tool in middleware.tools if tool.name == "grep")
backend_obj = middleware._get_backend(_runtime())
result_with_partial_matches = GrepResult(
error="Grep timed out after 30s with 1 matching file(s)",
matches=[{"path": "/test.py", "line": 1, "text": "import os"}],
)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "agrep", return_value=result_with_partial_matches),
):
result = await grep_search_tool.ainvoke(
{
"pattern": "import",
"output_mode": "content",
"runtime": _runtime(),
}
)
assert result.status == "error"
assert "Grep timed out after 30s" in result.content
assert "Partial matches:" in result.content
assert "/test.py" in result.content
assert "1: import os" in result.content
async def test_agrep_search_shortterm_content_mode(self):
"""Test async grep with content mode."""
files = {