Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions libs/deepagents/deepagents/backends/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,3 +737,39 @@ def format_grep_matches(
if not matches:
return "No matches found"
return _format_grep_results(build_grep_results_dict(matches), output_mode)


_REGEX_SIGNAL_RE = re.compile(
r"\|" # alternation
r"|\.\*" # `.*` wildcard
r"|\.\+" # `.+` wildcard
r"|\\[.wWdDsSbB(){}\[\]|+*?^$]" # escaped regex metacharacters / classes
)
"""Strong signals that a pattern was written as a regex rather than literal text.

Deliberately conservative: bare `.`, `(`, `)`, `[`, `]`, `?`, `^`, `$` are
omitted because they appear routinely in literal code searches (e.g.
`self.tools`, `def __init__(self):`, `arr[0]`), which would cause false hints.
"""


def looks_like_regex(pattern: str) -> bool:
Comment thread
mdrxy marked this conversation as resolved.
Resolved
"""Heuristically detect regex syntax in a pattern meant for literal grep."""
return bool(_REGEX_SIGNAL_RE.search(pattern))


def regex_literal_hint(pattern: str) -> str | None:
"""Return a hint when a no-match pattern looks like an (unsupported) regex.

`grep` matches literal text, so regex metacharacters are searched verbatim
and silently miss.

Returns `None` when the pattern has no regex signals.
"""
if not looks_like_regex(pattern):
return None
return (
"Note: grep matches literal text, not regex, so characters like "
"`|`, `.*`, and `\\.` are searched verbatim. Run a separate literal "
"search for each alternative, or use the execute tool with `rg` for regex."
)
46 changes: 36 additions & 10 deletions libs/deepagents/deepagents/middleware/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
check_empty_content,
format_content_with_line_numbers,
format_grep_matches,
regex_literal_hint,
sanitize_tool_call_id as sanitize_tool_call_id,
truncate_if_too_long,
validate_path,
Expand Down Expand Up @@ -204,6 +205,7 @@ def _filter_grep_matches_by_permission(
def _format_grep_tool_result(
result: GrepResult,
output_mode: Literal["files_with_matches", "content", "count"],
pattern: str = "",
Comment thread
mdrxy marked this conversation as resolved.
Resolved
) -> tuple[str, Literal["success", "error"]]:
"""Format a backend grep result for the tool boundary."""
matches = result.matches or []
Expand All @@ -213,6 +215,8 @@ def _format_grep_tool_result(
formatted = format_grep_matches(matches, output_mode)
if result.error:
return f"{result.error}\n\nPartial matches:\n{formatted}", "error"
if not matches and (hint := regex_literal_hint(pattern)):
return f"{formatted}\n\n{hint}", "success"
return formatted, "success"


Expand Down Expand Up @@ -336,6 +340,20 @@ class FilesystemState(AgentState):
"""Files in the filesystem. Uses DeltaChannel with snapshots every ~50 pregel steps to bound read depth."""


GREP_GLOB_DESCRIPTION = (
"Glob pattern (NOT regex) limiting which files are searched, matched against "
"the file name (e.g. '*.py', '*.{ts,tsx}'). This is an in-tool file filter, "
Comment thread
open-swe[bot] marked this conversation as resolved.
Resolved
"not a call to the separate glob tool."
)

GREP_OUTPUT_MODE_DESCRIPTION = (
"Shape of the returned text. 'files_with_matches' (default): newline-separated "
"matching file paths. 'content': matching lines grouped by file, each formatted "
"'<line_number>: <line text>' (only the matched line, no surrounding context). "
"'count': one '<path>: <match_count>' line per file."
)


class LsSchema(BaseModel):
"""Input schema for the `ls` tool."""

Expand Down Expand Up @@ -396,11 +414,11 @@ class GrepSchema(BaseModel):

path: str | None = Field(default=None, description="Directory to search in. Defaults to current working directory.")

glob: str | None = Field(default=None, description="Glob pattern to filter which files to search (e.g., '*.py').")
glob: str | None = Field(default=None, description=GREP_GLOB_DESCRIPTION)

output_mode: Literal["files_with_matches", "content", "count"] = Field(
default="files_with_matches",
description="Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
description=GREP_OUTPUT_MODE_DESCRIPTION,
)


Expand Down Expand Up @@ -470,16 +488,22 @@ class ExecuteSchema(BaseModel):
- `*.txt` - Find all text files in the backend's default root
- `/subdir/**/*.md` - Find all markdown files under /subdir"""

GREP_TOOL_DESCRIPTION = """Search for a text pattern across files.
GREP_TOOL_DESCRIPTION = """Search for a LITERAL text pattern across files (NOT regex).

Returns matching files or content based on output_mode. The pattern is matched
verbatim: regex metacharacters are treated as ordinary characters, NOT operators.

Searches for literal text (not regex) and returns matching files or content based on output_mode.
Special characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.
Do NOT pass a regex. In particular:
- To match any of several strings, run a SEPARATE grep for each one. There is no
`|` alternation: `grep(pattern="foo|bar")` looks for the literal text "foo|bar".
- Do not use wildcards (`.*`) or escapes (`\\.`); they match those characters literally.
- If you genuinely need regex, use the execute tool with `rg '<regex>'` instead.

Examples:
- Search all files: `grep(pattern="TODO")`
- Search Python files only: `grep(pattern="import", glob="*.py")`
- Show matching lines: `grep(pattern="error", output_mode="content")`
- Search for code with special chars: `grep(pattern="def __init__(self):")`"""
- Literal special chars are fine: `grep(pattern="def __init__(self):")`"""

EXECUTE_TOOL_DESCRIPTION = """Executes a shell command in an isolated sandbox environment.

Expand Down Expand Up @@ -1580,10 +1604,10 @@ def sync_grep(
pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
runtime: ToolRuntime[None, FilesystemState],
path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
glob: Annotated[str | None, GREP_GLOB_DESCRIPTION] = None,
output_mode: Annotated[
Literal["files_with_matches", "content", "count"],
"Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
GREP_OUTPUT_MODE_DESCRIPTION,
] = "files_with_matches",
) -> ToolMessage:
"""Synchronous wrapper for grep tool."""
Expand Down Expand Up @@ -1611,6 +1635,7 @@ def sync_grep(
formatted, status = _format_grep_tool_result(
GrepResult(error=grep_result.error, matches=filtered_matches),
output_mode,
pattern,
)
return ToolMessage(
content=truncate_if_too_long(formatted),
Expand All @@ -1623,10 +1648,10 @@ async def async_grep(
pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
runtime: ToolRuntime[None, FilesystemState],
path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
glob: Annotated[str | None, GREP_GLOB_DESCRIPTION] = None,
output_mode: Annotated[
Literal["files_with_matches", "content", "count"],
"Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
GREP_OUTPUT_MODE_DESCRIPTION,
] = "files_with_matches",
) -> ToolMessage:
"""Asynchronous wrapper for grep tool."""
Expand Down Expand Up @@ -1654,6 +1679,7 @@ async def async_grep(
formatted, status = _format_grep_tool_result(
GrepResult(error=grep_result.error, matches=filtered_matches),
output_mode,
pattern,
)
return ToolMessage(
content=truncate_if_too_long(formatted),
Expand Down
44 changes: 44 additions & 0 deletions libs/deepagents/tests/unit_tests/backends/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,57 @@
_EXTENSION_TO_FILE_TYPE,
_get_file_type,
_glob_search_files,
looks_like_regex,
perform_string_replacement,
regex_literal_hint,
slice_read_response,
to_posix_path,
validate_path,
)


class TestLooksLikeRegex:
"""`looks_like_regex` flags regex syntax in patterns meant for literal grep."""

@pytest.mark.parametrize(
"pattern",
[
"foo|bar",
"def .*model",
"self\\.tools",
"a.+b",
"\\bword\\b",
"\\d+",
"\\w",
],
)
def test_detects_regex(self, pattern: str) -> None:
assert looks_like_regex(pattern) is True

@pytest.mark.parametrize(
"pattern",
[
"def __init__(self):",
"self.tools",
"arr[0]",
"plain text",
"TODO",
"a == b",
"",
],
)
def test_ignores_literal(self, pattern: str) -> None:
assert looks_like_regex(pattern) is False

def test_hint_present_for_regex(self) -> None:
hint = regex_literal_hint("foo|bar")
assert hint is not None
assert "literal text, not regex" in hint

def test_hint_absent_for_literal(self) -> None:
assert regex_literal_hint("plain text") is None


class TestToPosixPath:
"""`to_posix_path` is the load-bearing primitive for Windows path handling."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
},
{
"function": {
"description": "Search for a text pattern across files.\n\nSearches for literal text (not regex) and returns matching files or content based on output_mode.\nSpecial characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Search for code with special chars: `grep(pattern=\"def __init__(self):\")`",
"description": "Search for a LITERAL text pattern across files (NOT regex).\n\nReturns matching files or content based on output_mode. The pattern is matched\nverbatim: regex metacharacters are treated as ordinary characters, NOT operators.\n\nDo NOT pass a regex. In particular:\n- To match any of several strings, run a SEPARATE grep for each one. There is no\n `|` alternation: `grep(pattern=\"foo|bar\")` looks for the literal text \"foo|bar\".\n- Do not use wildcards (`.*`) or escapes (`\\.`); they match those characters literally.\n- If you genuinely need regex, use the execute tool with `rg '<regex>'` instead.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Literal special chars are fine: `grep(pattern=\"def __init__(self):\")`",
"name": "grep",
"parameters": {
"properties": {
Expand All @@ -191,11 +191,11 @@
}
],
"default": null,
"description": "Glob pattern to filter which files to search (e.g., '*.py')."
"description": "Glob pattern (NOT regex) limiting which files are searched, matched against the file name (e.g. '*.py', '*.{ts,tsx}'). This is an in-tool file filter, not a call to the separate glob tool."
},
"output_mode": {
"default": "files_with_matches",
"description": "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
"description": "Shape of the returned text. 'files_with_matches' (default): newline-separated matching file paths. 'content': matching lines grouped by file, each formatted '<line_number>: <line text>' (only the matched line, no surrounding context). 'count': one '<path>: <match_count>' line per file.",
"enum": [
"files_with_matches",
"content",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
},
{
"function": {
"description": "Search for a text pattern across files.\n\nSearches for literal text (not regex) and returns matching files or content based on output_mode.\nSpecial characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Search for code with special chars: `grep(pattern=\"def __init__(self):\")`",
"description": "Search for a LITERAL text pattern across files (NOT regex).\n\nReturns matching files or content based on output_mode. The pattern is matched\nverbatim: regex metacharacters are treated as ordinary characters, NOT operators.\n\nDo NOT pass a regex. In particular:\n- To match any of several strings, run a SEPARATE grep for each one. There is no\n `|` alternation: `grep(pattern=\"foo|bar\")` looks for the literal text \"foo|bar\".\n- Do not use wildcards (`.*`) or escapes (`\\.`); they match those characters literally.\n- If you genuinely need regex, use the execute tool with `rg '<regex>'` instead.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Literal special chars are fine: `grep(pattern=\"def __init__(self):\")`",
"name": "grep",
"parameters": {
"properties": {
Expand All @@ -191,11 +191,11 @@
}
],
"default": null,
"description": "Glob pattern to filter which files to search (e.g., '*.py')."
"description": "Glob pattern (NOT regex) limiting which files are searched, matched against the file name (e.g. '*.py', '*.{ts,tsx}'). This is an in-tool file filter, not a call to the separate glob tool."
},
"output_mode": {
"default": "files_with_matches",
"description": "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
"description": "Shape of the returned text. 'files_with_matches' (default): newline-separated matching file paths. 'content': matching lines grouped by file, each formatted '<line_number>: <line text>' (only the matched line, no surrounding context). 'count': one '<path>: <match_count>' line per file.",
"enum": [
"files_with_matches",
"content",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
},
{
"function": {
"description": "Search for a text pattern across files.\n\nSearches for literal text (not regex) and returns matching files or content based on output_mode.\nSpecial characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Search for code with special chars: `grep(pattern=\"def __init__(self):\")`",
"description": "Search for a LITERAL text pattern across files (NOT regex).\n\nReturns matching files or content based on output_mode. The pattern is matched\nverbatim: regex metacharacters are treated as ordinary characters, NOT operators.\n\nDo NOT pass a regex. In particular:\n- To match any of several strings, run a SEPARATE grep for each one. There is no\n `|` alternation: `grep(pattern=\"foo|bar\")` looks for the literal text \"foo|bar\".\n- Do not use wildcards (`.*`) or escapes (`\\.`); they match those characters literally.\n- If you genuinely need regex, use the execute tool with `rg '<regex>'` instead.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Literal special chars are fine: `grep(pattern=\"def __init__(self):\")`",
"name": "grep",
"parameters": {
"properties": {
Expand All @@ -191,11 +191,11 @@
}
],
"default": null,
"description": "Glob pattern to filter which files to search (e.g., '*.py')."
"description": "Glob pattern (NOT regex) limiting which files are searched, matched against the file name (e.g. '*.py', '*.{ts,tsx}'). This is an in-tool file filter, not a call to the separate glob tool."
},
"output_mode": {
"default": "files_with_matches",
"description": "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
"description": "Shape of the returned text. 'files_with_matches' (default): newline-separated matching file paths. 'content': matching lines grouped by file, each formatted '<line_number>: <line text>' (only the matched line, no surrounding context). 'count': one '<path>: <match_count>' line per file.",
"enum": [
"files_with_matches",
"content",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
},
{
"function": {
"description": "Search for a text pattern across files.\n\nSearches for literal text (not regex) and returns matching files or content based on output_mode.\nSpecial characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Search for code with special chars: `grep(pattern=\"def __init__(self):\")`",
"description": "Search for a LITERAL text pattern across files (NOT regex).\n\nReturns matching files or content based on output_mode. The pattern is matched\nverbatim: regex metacharacters are treated as ordinary characters, NOT operators.\n\nDo NOT pass a regex. In particular:\n- To match any of several strings, run a SEPARATE grep for each one. There is no\n `|` alternation: `grep(pattern=\"foo|bar\")` looks for the literal text \"foo|bar\".\n- Do not use wildcards (`.*`) or escapes (`\\.`); they match those characters literally.\n- If you genuinely need regex, use the execute tool with `rg '<regex>'` instead.\n\nExamples:\n- Search all files: `grep(pattern=\"TODO\")`\n- Search Python files only: `grep(pattern=\"import\", glob=\"*.py\")`\n- Show matching lines: `grep(pattern=\"error\", output_mode=\"content\")`\n- Literal special chars are fine: `grep(pattern=\"def __init__(self):\")`",
"name": "grep",
"parameters": {
"properties": {
Expand All @@ -191,11 +191,11 @@
}
],
"default": null,
"description": "Glob pattern to filter which files to search (e.g., '*.py')."
"description": "Glob pattern (NOT regex) limiting which files are searched, matched against the file name (e.g. '*.py', '*.{ts,tsx}'). This is an in-tool file filter, not a call to the separate glob tool."
},
"output_mode": {
"default": "files_with_matches",
"description": "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
"description": "Shape of the returned text. 'files_with_matches' (default): newline-separated matching file paths. 'content': matching lines grouped by file, each formatted '<line_number>: <line text>' (only the matched line, no surrounding context). 'count': one '<path>: <match_count>' line per file.",
"enum": [
"files_with_matches",
"content",
Expand Down
Loading
Loading