mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
fix(sandbox): allow grep to search a single file (#4512)
This commit is contained in:
parent
28553040fe
commit
d455a1815e
@ -893,6 +893,8 @@ DeerFlow doesn't just *talk* about doing things. It has its own computer.
|
||||
|
||||
Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands.
|
||||
|
||||
The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory.
|
||||
|
||||
Image bytes loaded for a vision-model call are transient: DeerFlow removes the hidden base64 message after the model consumes it so later checkpoints do not keep duplicating that payload.
|
||||
|
||||
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only.
|
||||
|
||||
@ -536,7 +536,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
|
||||
|
||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||
|
||||
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
|
||||
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. `grep` accepts either one text file or a directory tree. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
|
||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
|
||||
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
|
||||
**Implementations**:
|
||||
@ -606,6 +606,8 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
|
||||
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
||||
- `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), POSIX output is captured through bounded pipe-drain threads and stdin is `/dev/null`, so a backgrounded long-lived process (`server &`) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (`sandbox.bash_command_timeout`, default 600s); on timeout the whole group is killed and the agent gets a notice telling it to background long-lived processes. The bash tool description itself also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. See `LocalSandbox.execute_command` / `_run_posix_command` and `bash_tool`'s docstring.
|
||||
- `ls` - Directory listing (tree format, max 2 levels)
|
||||
- `glob` - Find files or directories below a root directory with bounded results
|
||||
- `grep` - Search one text file or recursively search a directory, with optional glob filtering and bounded line-level results
|
||||
- `read_file` - Read file contents with optional line range
|
||||
- `write_file` - Write/append to files, creates directories; overwrites by default and exposes the `append` argument in the model-facing schema for end-of-file writes; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain)
|
||||
- `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain)
|
||||
|
||||
@ -422,41 +422,45 @@ class AioSandbox(Sandbox):
|
||||
# (caught by grep_tool's except re.error handler) rather than a
|
||||
# generic remote API error.
|
||||
_re.compile(regex_source, 0 if case_sensitive else _re.IGNORECASE)
|
||||
regex = regex_source if case_sensitive else f"(?i){regex_source}"
|
||||
|
||||
if glob is not None:
|
||||
find_result = self._client.file.find_files(path=path, glob=glob)
|
||||
candidate_paths = find_result.data.files if find_result.data and find_result.data.files else []
|
||||
else:
|
||||
list_result = self._client.file.list_path(path=path, recursive=True, show_hidden=False)
|
||||
entries = list_result.data.files if list_result.data and list_result.data.files else []
|
||||
candidate_paths = [entry.path for entry in entries if not entry.is_directory]
|
||||
total_cap = max(max_results * 4, max_results + 50)
|
||||
result = self._client.file.grep_files(
|
||||
path=path,
|
||||
pattern=pattern,
|
||||
case_insensitive=not case_sensitive,
|
||||
fixed_strings=literal,
|
||||
max_results=total_cap,
|
||||
max_file_size="1M",
|
||||
recursive=True,
|
||||
)
|
||||
data = result.data
|
||||
provider_matches = data.matches if data and data.matches else []
|
||||
root = path.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = False
|
||||
|
||||
for file_path in candidate_paths:
|
||||
truncated = bool(data and data.truncated)
|
||||
for match in provider_matches:
|
||||
file_path = match.file
|
||||
if should_ignore_path(file_path):
|
||||
continue
|
||||
|
||||
search_result = self._client.file.search_in_file(file=file_path, regex=regex)
|
||||
data = search_result.data
|
||||
if data is None:
|
||||
if file_path == root:
|
||||
rel_path = file_path.rsplit("/", 1)[-1]
|
||||
elif file_path.startswith(root_prefix):
|
||||
rel_path = file_path[len(root_prefix) :]
|
||||
else:
|
||||
continue
|
||||
|
||||
line_numbers = data.line_numbers or []
|
||||
matched_lines = data.matches or []
|
||||
for line_number, line in zip(line_numbers, matched_lines):
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
path=file_path,
|
||||
line_number=line_number if isinstance(line_number, int) else 0,
|
||||
line=truncate_line(line),
|
||||
)
|
||||
if glob is not None and not path_matches(glob, rel_path):
|
||||
continue
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
path=file_path,
|
||||
line_number=match.line_number,
|
||||
line=truncate_line(match.line_content),
|
||||
)
|
||||
if len(matches) >= max_results:
|
||||
truncated = True
|
||||
return matches, truncated
|
||||
)
|
||||
if len(matches) >= max_results:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return matches, truncated
|
||||
|
||||
|
||||
@ -325,11 +325,11 @@ class BoxliteBox(Sandbox):
|
||||
re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
|
||||
|
||||
resolved = self._resolve_path(path)
|
||||
# busybox+GNU-portable flags: -r recursive (also prints the filename),
|
||||
# -n line numbers, -I skip binary, -E/-F regex vs fixed. --include and -m
|
||||
# are omitted for busybox portability; glob-scoping and the result cap are
|
||||
# applied in Python below.
|
||||
flags = ["-r", "-n", "-I"]
|
||||
# busybox+GNU-portable flags: -r recursive, -H always print the filename
|
||||
# (including when path is a single file), -n line numbers, -I skip
|
||||
# binary, -E/-F regex vs fixed. --include and -m are omitted for busybox
|
||||
# portability; glob-scoping and the result cap are applied in Python.
|
||||
flags = ["-r", "-H", "-n", "-I"]
|
||||
if not case_sensitive:
|
||||
flags.append("-i")
|
||||
flags.append("-F" if literal else "-E")
|
||||
|
||||
@ -476,8 +476,8 @@ class E2BSandbox(Sandbox):
|
||||
# ``--include`` flag above only pre-filtered by basename.
|
||||
if file_path != root and not file_path.startswith(root_prefix):
|
||||
continue
|
||||
rel_path = file_path[len(root) :].lstrip("/")
|
||||
if not rel_path or not path_matches(glob, rel_path):
|
||||
rel_path = file_path.rsplit("/", 1)[-1] if file_path == root else file_path[len(root) :].lstrip("/")
|
||||
if not path_matches(glob, rel_path):
|
||||
continue
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
|
||||
@ -448,8 +448,8 @@ class TenkiSandbox(Sandbox):
|
||||
# helper, same relative-to-root semantics as glob() above.
|
||||
if file_path != root and not file_path.startswith(root_prefix):
|
||||
continue
|
||||
rel_path = file_path[len(root) :].lstrip("/")
|
||||
if not rel_path or not path_matches(glob, rel_path):
|
||||
rel_path = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/")
|
||||
if not path_matches(glob, rel_path):
|
||||
continue
|
||||
matches.append(GrepMatch(path=self._virtual_path(file_path), line_number=line_number, line=truncate_line(line_text)))
|
||||
if len(matches) >= max_results:
|
||||
|
||||
@ -161,7 +161,7 @@ class Sandbox(ABC):
|
||||
case_sensitive: bool = False,
|
||||
max_results: int = 100,
|
||||
) -> tuple[list[GrepMatch], bool]:
|
||||
"""Search for matches inside text files under a directory."""
|
||||
"""Search for matches inside a text file or files under a directory."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@ -169,7 +169,8 @@ def find_grep_matches(
|
||||
|
||||
if not root.exists():
|
||||
raise FileNotFoundError(root)
|
||||
if not root.is_dir():
|
||||
root_is_file = root.is_file()
|
||||
if not root_is_file and not root.is_dir():
|
||||
raise NotADirectoryError(root)
|
||||
|
||||
regex_source = re.escape(pattern) if literal else pattern
|
||||
@ -179,44 +180,47 @@ def find_grep_matches(
|
||||
# Skip lines longer than this to prevent ReDoS on minified / no-newline files.
|
||||
_max_line_chars = line_summary_length * 10
|
||||
|
||||
for current_root, dirs, files in os.walk(root):
|
||||
dirs[:] = [name for name in dirs if not should_ignore_name(name)]
|
||||
rel_dir = Path(current_root).relative_to(root)
|
||||
def candidate_files():
|
||||
if root_is_file:
|
||||
yield root, root.name
|
||||
return
|
||||
|
||||
for name in files:
|
||||
if should_ignore_name(name):
|
||||
for current_root, dirs, files in os.walk(root):
|
||||
dirs[:] = [name for name in dirs if not should_ignore_name(name)]
|
||||
rel_dir = Path(current_root).relative_to(root)
|
||||
for name in files:
|
||||
if should_ignore_name(name):
|
||||
continue
|
||||
yield Path(current_root) / name, (rel_dir / name).as_posix()
|
||||
|
||||
for candidate_path, rel_path in candidate_files():
|
||||
if glob_pattern is not None and not path_matches(glob_pattern, rel_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
if not root_is_file and candidate_path.is_symlink():
|
||||
continue
|
||||
|
||||
candidate_path = Path(current_root) / name
|
||||
rel_path = (rel_dir / name).as_posix()
|
||||
|
||||
if glob_pattern is not None and not path_matches(glob_pattern, rel_path):
|
||||
file_path = candidate_path.resolve()
|
||||
if not root_is_file and not file_path.is_relative_to(root):
|
||||
continue
|
||||
|
||||
try:
|
||||
if candidate_path.is_symlink():
|
||||
continue
|
||||
file_path = candidate_path.resolve()
|
||||
if not file_path.is_relative_to(root):
|
||||
continue
|
||||
if file_path.stat().st_size > max_file_size or is_binary_file(file_path):
|
||||
continue
|
||||
with file_path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line_number, line in enumerate(handle, start=1):
|
||||
if len(line) > _max_line_chars:
|
||||
continue
|
||||
if regex.search(line):
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
path=str(file_path),
|
||||
line_number=line_number,
|
||||
line=truncate_line(line, line_summary_length),
|
||||
)
|
||||
if file_path.stat().st_size > max_file_size or is_binary_file(file_path):
|
||||
continue
|
||||
with file_path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line_number, line in enumerate(handle, start=1):
|
||||
if len(line) > _max_line_chars:
|
||||
continue
|
||||
if regex.search(line):
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
path=str(file_path),
|
||||
line_number=line_number,
|
||||
line=truncate_line(line, line_summary_length),
|
||||
)
|
||||
if len(matches) >= max_results:
|
||||
truncated = True
|
||||
return matches, truncated
|
||||
except OSError:
|
||||
continue
|
||||
)
|
||||
if len(matches) >= max_results:
|
||||
truncated = True
|
||||
return matches, truncated
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return matches, truncated
|
||||
|
||||
@ -1993,12 +1993,12 @@ def grep_tool(
|
||||
case_sensitive: bool = False,
|
||||
max_results: int = _DEFAULT_GREP_MAX_RESULTS,
|
||||
) -> str:
|
||||
"""Search for matching lines inside text files under a root directory.
|
||||
"""Search for matching lines inside a text file or files under a root directory.
|
||||
|
||||
Args:
|
||||
description: Explain why you are searching file contents in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
pattern: The string or regex pattern to search for.
|
||||
path: The **absolute** root directory to search under.
|
||||
path: The **absolute** file or root directory to search.
|
||||
glob: Optional glob filter for candidate files, for example `**/*.py`.
|
||||
literal: Whether to treat `pattern` as a plain string. Default is False.
|
||||
case_sensitive: Whether matching is case-sensitive. Default is False.
|
||||
|
||||
@ -5,7 +5,7 @@ description = "DeerFlow agent harness framework"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"agent-client-protocol>=0.4.0",
|
||||
"agent-sandbox>=0.0.19",
|
||||
"agent-sandbox>=0.0.30",
|
||||
"croniter>=6.0.0",
|
||||
"dotenv>=0.9.9",
|
||||
"exa-py>=1.0.0",
|
||||
|
||||
@ -165,6 +165,17 @@ def test_execute_command_forwards_timeout_to_sdk_and_loop_runner() -> None:
|
||||
assert run_timeouts == [5]
|
||||
|
||||
|
||||
def test_grep_always_prints_filename_for_single_file_paths() -> None:
|
||||
fake = _FakeBox(name="box-id")
|
||||
box = BoxliteBox("box-id", box=fake, run=_fake_run)
|
||||
|
||||
box.grep("/mnt/user-data/uploads/report.md", "needle")
|
||||
|
||||
grep_commands = [argv[0][2] for argv in fake._exec_history if argv[0][:2] == ("sh", "-lc") and argv[0][2].startswith("grep ")]
|
||||
assert grep_commands
|
||||
assert "-H" in grep_commands[-1].split()
|
||||
|
||||
|
||||
def test_execute_command_invalidates_box_on_terminal_transport_error() -> None:
|
||||
invalidated: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
@ -2072,6 +2072,18 @@ def test_grep_without_glob_is_unaffected():
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_grep_single_file_path_with_matching_glob():
|
||||
"""A basename glob must also apply when the search root is one file."""
|
||||
raw_stdout = "/home/user/uploads/report.md:2:needle here\n"
|
||||
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=raw_stdout, stderr="", exit_code=0)]))
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
matches, truncated = sb.grep("/mnt/user-data/uploads/report.md", "needle", glob="*.md")
|
||||
|
||||
assert [m.path for m in matches] == ["/home/user/uploads/report.md"]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Capacity enforcement tests (#4339)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -101,6 +101,26 @@ def test_grep_tool_filters_by_glob_and_skips_binary_files(tmp_path, monkeypatch)
|
||||
assert str(workspace) not in result
|
||||
|
||||
|
||||
def test_grep_tool_accepts_single_file_path(tmp_path, monkeypatch) -> None:
|
||||
runtime = _make_runtime(tmp_path)
|
||||
uploads = tmp_path / "uploads"
|
||||
report = uploads / "report.md"
|
||||
report.write_text("Revenue grew 20%\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
||||
|
||||
result = grep_tool.func(
|
||||
runtime=runtime,
|
||||
description="find revenue in the uploaded report",
|
||||
pattern="Revenue",
|
||||
path="/mnt/user-data/uploads/report.md",
|
||||
)
|
||||
|
||||
assert "/mnt/user-data/uploads/report.md:1: Revenue grew 20%" in result
|
||||
assert "Path is not a directory" not in result
|
||||
assert str(uploads) not in result
|
||||
|
||||
|
||||
def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None:
|
||||
runtime = _make_runtime(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
@ -260,24 +280,20 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None:
|
||||
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"list_path",
|
||||
"grep_files",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
data=SimpleNamespace(
|
||||
files=[
|
||||
matches=[
|
||||
SimpleNamespace(
|
||||
name="app.py",
|
||||
path="/mnt/user-data/workspace/app.py",
|
||||
is_directory=False,
|
||||
file="/mnt/user-data/workspace/app.py",
|
||||
line_number=7,
|
||||
line_content="TODO = True",
|
||||
)
|
||||
]
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"search_in_file",
|
||||
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True"])),
|
||||
)
|
||||
|
||||
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")
|
||||
|
||||
@ -285,6 +301,37 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None:
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_aio_sandbox_grep_accepts_single_file_path(monkeypatch) -> None:
|
||||
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
||||
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"grep_files",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
data=SimpleNamespace(
|
||||
matches=[
|
||||
SimpleNamespace(
|
||||
file="/mnt/user-data/uploads/report.md",
|
||||
line_number=3,
|
||||
line_content="Revenue grew 20%",
|
||||
)
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"list_path",
|
||||
lambda **kwargs: (_ for _ in ()).throw(AssertionError("single-file grep must not list the path as a directory")),
|
||||
)
|
||||
|
||||
matches, truncated = sandbox.grep("/mnt/user-data/uploads/report.md", "Revenue")
|
||||
|
||||
assert matches == [GrepMatch(path="/mnt/user-data/uploads/report.md", line_number=3, line="Revenue grew 20%")]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None:
|
||||
file_path = tmp_path / "file.txt"
|
||||
file_path.write_text("x\n", encoding="utf-8")
|
||||
@ -296,15 +343,14 @@ def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_find_grep_matches_raises_not_a_directory(tmp_path) -> None:
|
||||
def test_find_grep_matches_accepts_single_file(tmp_path) -> None:
|
||||
file_path = tmp_path / "file.txt"
|
||||
file_path.write_text("TODO\n", encoding="utf-8")
|
||||
|
||||
try:
|
||||
find_grep_matches(file_path, "TODO")
|
||||
assert False, "Expected NotADirectoryError"
|
||||
except NotADirectoryError:
|
||||
pass
|
||||
matches, truncated = find_grep_matches(file_path, "TODO")
|
||||
|
||||
assert matches == [GrepMatch(path=str(file_path), line_number=1, line="TODO")]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None:
|
||||
@ -367,29 +413,30 @@ def test_aio_sandbox_glob_include_dirs_enforces_root_boundary(monkeypatch) -> No
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> None:
|
||||
def test_aio_sandbox_grep_drops_matches_outside_requested_root(monkeypatch) -> None:
|
||||
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
||||
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"list_path",
|
||||
"grep_files",
|
||||
lambda **kwargs: SimpleNamespace(
|
||||
data=SimpleNamespace(
|
||||
files=[
|
||||
matches=[
|
||||
SimpleNamespace(
|
||||
name="app.py",
|
||||
path="/mnt/user-data/workspace/app.py",
|
||||
is_directory=False,
|
||||
)
|
||||
]
|
||||
file="/mnt/user-data/workspace/app.py",
|
||||
line_number=7,
|
||||
line_content="TODO = True",
|
||||
),
|
||||
SimpleNamespace(
|
||||
file="/mnt/user-data/workspace-sibling/leak.py",
|
||||
line_number=9,
|
||||
line_content="TODO = False",
|
||||
),
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sandbox._client.file,
|
||||
"search_in_file",
|
||||
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True", "extra"])),
|
||||
)
|
||||
|
||||
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")
|
||||
|
||||
|
||||
@ -543,6 +543,16 @@ def test_grep_passes_capital_h_so_single_file_matches_parse() -> None:
|
||||
assert grep_scripts and "-H" in shlex.split(grep_scripts[0])
|
||||
|
||||
|
||||
def test_grep_single_file_path_with_matching_glob() -> None:
|
||||
box = TenkiSandbox("sb", _FakeSandbox())
|
||||
box.write_file("/mnt/user-data/workspace/a.txt", "needle here\n")
|
||||
|
||||
matches, truncated = box.grep("/mnt/user-data/workspace/a.txt", "needle", glob="*.txt")
|
||||
|
||||
assert [m.path for m in matches] == ["/mnt/user-data/workspace/a.txt"]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def _grep_script(fake: _FakeSandbox) -> list[str]:
|
||||
scripts = [c["argv"][2] for c in fake.exec_calls if c["argv"][:2] == ("sh", "-lc") and c["argv"][2].startswith("grep ")]
|
||||
assert scripts, "no grep command was issued"
|
||||
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@ -952,7 +952,7 @@ tui = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", specifier = ">=0.4.0" },
|
||||
{ name = "agent-sandbox", specifier = ">=0.0.19" },
|
||||
{ name = "agent-sandbox", specifier = ">=0.0.30" },
|
||||
{ name = "aiosqlite", specifier = ">=0.19" },
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user