mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user