mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
perf(sdk): cache grep glob matchers (#3887)
Caches compiled glob matchers used by in-memory grep helpers and hoists the Context Hub glob translation outside the per-file loop. This avoids recompiling the same glob pattern for every candidate file while preserving brace expansion and existing glob semantics. ### Benchmarks Focused local microbenchmarks compared the original combined branch against `main` on Python 3.14.2: - State backend `_grep_search_files` with glob filtering: **3.50x faster** (`main`: 0.045913s, PR: 0.013116s median; ~71.4% improvement). - State backend `grep_matches_from_files` with glob filtering: **3.71x faster** (`main`: 0.044786s, PR: 0.012071s median; ~73.0% improvement).
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -253,10 +254,12 @@ class ContextHubBackend(BackendProtocol):
|
||||
|
||||
prefix = self._strip_prefix(path).rstrip("/") if path else ""
|
||||
|
||||
glob_re = re.compile(fnmatch.translate(os.path.normcase(glob))) if glob else None
|
||||
|
||||
for file_path, content in cache.items():
|
||||
if prefix and not file_path.startswith(prefix):
|
||||
continue
|
||||
if glob and not fnmatch.fnmatch(file_path, glob):
|
||||
if glob_re is not None and not glob_re.match(os.path.normcase(file_path)):
|
||||
continue
|
||||
for i, line in enumerate(content.splitlines(), start=1):
|
||||
if regex.search(line):
|
||||
|
||||
@@ -5,6 +5,7 @@ helpers used by backends and the composite router. Structured helpers
|
||||
enable composition without fragile string parsing.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
@@ -72,6 +73,12 @@ FileInfo = _FileInfo
|
||||
GrepMatch = _GrepMatch
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _compile_glob(pattern: str) -> wcglob.WcMatcher:
|
||||
"""Compile a glob pattern once and cache it (BRACE flag)."""
|
||||
return wcglob.compile(pattern, flags=wcglob.BRACE)
|
||||
|
||||
|
||||
def _normalize_content(file_data: FileData) -> str:
|
||||
"""Normalize file_data content to a plain string.
|
||||
|
||||
@@ -673,61 +680,6 @@ def _format_grep_results(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _grep_search_files(
|
||||
files: dict[str, Any],
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
|
||||
) -> str:
|
||||
r"""Search file contents for regex pattern.
|
||||
|
||||
Args:
|
||||
files: Dictionary of file paths to FileData.
|
||||
pattern: Regex pattern to search for.
|
||||
path: Base path to search from.
|
||||
glob: Optional glob pattern to filter files (e.g., "*.py").
|
||||
output_mode: Output format - "files_with_matches", "content", or "count".
|
||||
|
||||
Returns:
|
||||
Formatted search results. Returns "No matches found" if no results.
|
||||
|
||||
Example:
|
||||
```python
|
||||
files = {"/file.py": FileData(content="import os\nprint('hi')", ...)}
|
||||
_grep_search_files(files, "import", "/")
|
||||
# Returns: "/file.py" (with output_mode="files_with_matches")
|
||||
```
|
||||
"""
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error as e:
|
||||
return f"Invalid regex pattern: {e}"
|
||||
|
||||
try:
|
||||
normalized_path = _normalize_path(path)
|
||||
except ValueError:
|
||||
return "No matches found"
|
||||
|
||||
filtered = _filter_files_by_path(files, normalized_path)
|
||||
|
||||
if glob:
|
||||
filtered = {fp: fd for fp, fd in filtered.items() if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)}
|
||||
|
||||
results: dict[str, list[tuple[int, str]]] = {}
|
||||
for file_path, file_data in filtered.items():
|
||||
content_str = _normalize_content(file_data)
|
||||
for line_num, line in enumerate(content_str.split("\n"), 1):
|
||||
if regex.search(line):
|
||||
if file_path not in results:
|
||||
results[file_path] = []
|
||||
results[file_path].append((line_num, line))
|
||||
|
||||
if not results:
|
||||
return "No matches found"
|
||||
return _format_grep_results(results, output_mode)
|
||||
|
||||
|
||||
# -------- Structured helpers for composition --------
|
||||
|
||||
|
||||
@@ -753,7 +705,8 @@ def grep_matches_from_files(
|
||||
filtered = _filter_files_by_path(files, normalized_path)
|
||||
|
||||
if glob:
|
||||
filtered = {fp: fd for fp, fd in filtered.items() if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)}
|
||||
matcher = _compile_glob(glob)
|
||||
filtered = {fp: fd for fp, fd in filtered.items() if matcher.match(Path(fp).name)}
|
||||
|
||||
matches: list[GrepMatch] = []
|
||||
for file_path, file_data in filtered.items():
|
||||
|
||||
@@ -22,10 +22,10 @@ classifiers = [
|
||||
dependencies = [
|
||||
"langchain-core>=1.4.6,<2.0.0",
|
||||
"langsmith>=0.8.11",
|
||||
"langchain>=1.3.7,<2.0.0",
|
||||
"langchain-anthropic>=1.4.5,<2.0.0",
|
||||
"langchain-google-genai>=4.2.5,<5.0.0",
|
||||
"wcmatch",
|
||||
"langchain>=1.3.7,<2.0.0",
|
||||
"wcmatch>=10.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -303,6 +303,61 @@ def test_grep_with_path_prefix() -> None:
|
||||
assert paths == {"/memories/a.md"}
|
||||
|
||||
|
||||
def test_grep_with_glob_filter() -> None:
|
||||
"""`grep` honors the glob filter via the precompiled, hoisted matcher."""
|
||||
backend, _ = _make_backend(
|
||||
**{
|
||||
"src/main.py": FileEntry(type="file", content="import os"),
|
||||
"src/notes.md": FileEntry(type="file", content="import os"),
|
||||
}
|
||||
)
|
||||
result = backend.grep("import", glob="*.py")
|
||||
assert result.matches is not None
|
||||
paths = {m["path"] for m in result.matches}
|
||||
assert paths == {"/src/main.py"}
|
||||
|
||||
|
||||
def test_grep_glob_matches_full_path_not_basename() -> None:
|
||||
"""A directory-qualified glob matches against the full path, not the basename.
|
||||
|
||||
Context Hub matches the glob against the whole file path, so `src/*.py`
|
||||
selects the nested file but excludes a same-named top-level file. A
|
||||
basename-only matcher would select neither, so this pins the intended
|
||||
full-path semantics.
|
||||
"""
|
||||
backend, _ = _make_backend(
|
||||
**{
|
||||
"src/main.py": FileEntry(type="file", content="import os"),
|
||||
"main.py": FileEntry(type="file", content="import os"),
|
||||
}
|
||||
)
|
||||
result = backend.grep("import", glob="src/*.py")
|
||||
assert result.matches is not None
|
||||
paths = {m["path"] for m in result.matches}
|
||||
assert paths == {"/src/main.py"}
|
||||
|
||||
|
||||
def test_grep_glob_does_not_brace_expand() -> None:
|
||||
"""Context Hub uses `fnmatch`, which treats braces literally.
|
||||
|
||||
Unlike the wcmatch-backed in-memory grep helpers (which enable brace
|
||||
expansion), `*.{py,md}` here matches a file literally named `x.{py,md}`
|
||||
rather than expanding to `*.py` or `*.md`. This locks in the intended
|
||||
asymmetry between the two grep paths.
|
||||
"""
|
||||
backend, _ = _make_backend(
|
||||
**{
|
||||
"a.py": FileEntry(type="file", content="hit"),
|
||||
"b.md": FileEntry(type="file", content="hit"),
|
||||
"literal.{py,md}": FileEntry(type="file", content="hit"),
|
||||
}
|
||||
)
|
||||
result = backend.grep("hit", glob="*.{py,md}")
|
||||
assert result.matches is not None
|
||||
paths = {m["path"] for m in result.matches}
|
||||
assert paths == {"/literal.{py,md}"}
|
||||
|
||||
|
||||
def test_grep_invalid_regex() -> None:
|
||||
backend, _ = _make_backend()
|
||||
result = backend.grep("[unclosed")
|
||||
|
||||
@@ -18,6 +18,7 @@ from langgraph.store.memory import InMemoryStore
|
||||
from deepagents.backends.protocol import ReadResult
|
||||
from deepagents.backends.store import StoreBackend
|
||||
from deepagents.backends.utils import (
|
||||
_compile_glob,
|
||||
_to_legacy_file_data,
|
||||
create_file_data,
|
||||
file_data_to_string,
|
||||
@@ -264,6 +265,65 @@ def test_grep_new_format():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grep_glob_filters_by_filename():
|
||||
"""`grep_matches_from_files` honors the glob filter (cached compile path)."""
|
||||
files = {
|
||||
"/src/main.py": create_file_data("import os"),
|
||||
"/src/notes.txt": create_file_data("import os"),
|
||||
}
|
||||
result = grep_matches_from_files(files, "import", path="/", glob="*.py")
|
||||
assert result.matches is not None
|
||||
assert len(result.matches) == 1
|
||||
assert result.matches[0]["path"] == "/src/main.py"
|
||||
|
||||
|
||||
def test_grep_glob_brace_expansion():
|
||||
"""Brace-expansion globs match either extension via the cached matcher."""
|
||||
files = {
|
||||
"/a.py": create_file_data("hit"),
|
||||
"/b.md": create_file_data("hit"),
|
||||
"/c.txt": create_file_data("hit"),
|
||||
}
|
||||
result = grep_matches_from_files(files, "hit", path="/", glob="*.{py,md}")
|
||||
paths = sorted(m["path"] for m in result.matches)
|
||||
assert paths == ["/a.py", "/b.md"]
|
||||
|
||||
|
||||
def test_grep_glob_matches_nothing():
|
||||
"""A glob matching no files yields an empty (not `None`) match list."""
|
||||
files = {
|
||||
"/a.py": create_file_data("hit"),
|
||||
"/b.md": create_file_data("hit"),
|
||||
}
|
||||
result = grep_matches_from_files(files, "hit", path="/", glob="*.rs")
|
||||
assert result.matches == []
|
||||
|
||||
|
||||
def test_compile_glob_is_cached():
|
||||
"""`_compile_glob` returns the identical matcher object for a repeated pattern.
|
||||
|
||||
This is the optimization the change exists to provide: the compiled matcher
|
||||
is reused across calls rather than recompiled per candidate file.
|
||||
"""
|
||||
assert _compile_glob("*.py") is _compile_glob("*.py")
|
||||
# A distinct pattern produces a distinct matcher.
|
||||
assert _compile_glob("*.py") is not _compile_glob("*.md")
|
||||
|
||||
|
||||
def test_grep_glob_repeated_pattern_stays_correct():
|
||||
"""Repeated greps with the same glob return correct results each time.
|
||||
|
||||
Guards against a cached matcher being mutated or carrying state between
|
||||
calls, which would corrupt the second invocation.
|
||||
"""
|
||||
first = {"/x.py": create_file_data("hit"), "/x.md": create_file_data("hit")}
|
||||
second = {"/y.py": create_file_data("hit"), "/y.txt": create_file_data("hit")}
|
||||
r1 = grep_matches_from_files(first, "hit", path="/", glob="*.py")
|
||||
r2 = grep_matches_from_files(second, "hit", path="/", glob="*.py")
|
||||
assert [m["path"] for m in r1.matches] == ["/x.py"]
|
||||
assert [m["path"] for m in r2.matches] == ["/y.py"]
|
||||
|
||||
|
||||
def test_grep_legacy_format():
|
||||
legacy_fd = {
|
||||
"content": ["def foo():", " return 42", "def bar():", " return 0"],
|
||||
|
||||
Generated
+1
-1
@@ -455,7 +455,7 @@ requires-dist = [
|
||||
{ name = "langchain-google-genai", specifier = ">=4.2.5,<5.0.0" },
|
||||
{ name = "langchain-quickjs", marker = "extra == 'quickjs'" },
|
||||
{ name = "langsmith", specifier = ">=0.8.11" },
|
||||
{ name = "wcmatch" },
|
||||
{ name = "wcmatch", specifier = ">=10.1" },
|
||||
]
|
||||
provides-extras = ["quickjs"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user