diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ddc7e8ce..be811bd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -582,6 +582,15 @@ This section accumulates work toward the **2.1.0** milestone ### Fixed +- **sandbox:** Stop remote `glob` and `grep` from reporting "no matches" when + their output was cut off. BoxLite, Tenki, E2B, and OpenSandbox cap the + search's raw output and then filter it in Python (ignored directories such as + `node_modules`, the pattern or `glob` scope), but they reported `truncated` + only when `max_results` was reached. When the capped lines were all filtered + out, a search with real matches past the cap came back empty and complete. + The search now passes one line beyond its cap so a cut-off result is reported + as truncated, and the `glob` and `grep` tools say an empty truncated result is + incomplete instead of "No matches found". ([#5427]) - **sandbox:** Stop host paths reaching the model when output joins them with `:`, as `$PATH` and `$PYTHONPATH` do. The matched path ran on through the rest of the list, so every later entry under the same root was left @@ -2832,3 +2841,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5419]: https://github.com/bytedance/deer-flow/pull/5419 +[#5427]: https://github.com/bytedance/deer-flow/pull/5427 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 3b803529d..7b97b4b1b 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -397,6 +397,11 @@ ### 修复 +- **沙箱:** 远程 `glob` 与 `grep` 的输出被截断时,不再报告"没有匹配"。BoxLite、Tenki、E2B 与 + OpenSandbox 会先限制搜索的原始输出行数,再在 Python 中过滤(`node_modules` 等忽略目录、匹配模式或 `glob` + 范围),但只有达到 `max_results` 时才报告 `truncated`。若被截取的行全部被过滤掉,截断位置之后仍有 + 真实匹配的搜索会返回空结果且显示为完整。现在搜索会多输出一行以判断是否被截断,`glob` 和 `grep` + 工具对被截断的空结果会说明结果不完整,而不是显示 "No matches found"。([#5427]) - **沙箱:** 当输出用 `:` 连接主机路径(如 `$PATH`、`$PYTHONPATH`)时,主机路径不再暴露给模型。 匹配的路径会一直延伸到列表末尾,导致同一根目录下之后的条目都未被遮蔽;多余的遮蔽轮次每次 恰好补回一个条目,因此短列表掩盖了这一泄露。现在遮蔽时匹配的路径在 `:` 处结束。 @@ -2167,3 +2172,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5419]: https://github.com/bytedance/deer-flow/pull/5419 +[#5427]: https://github.com/bytedance/deer-flow/pull/5427 diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index 551d740c0..6664d1379 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -311,12 +311,12 @@ class BoxliteBox(Sandbox): search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null" r = self._sh(remote_search_command(search, resolved, limit=hard_limit)) # A missing root or a failed find must not read as "no files matched" (#5376). - output = parse_remote_search_output(r.stdout, resolved, tool="find") + output = parse_remote_search_output(r.stdout, resolved, tool="find", limit=hard_limit) matches: list[str] = [] root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" - for entry in output.splitlines(): + for entry in output.text.splitlines(): # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)): continue @@ -329,7 +329,7 @@ class BoxliteBox(Sandbox): matches.append(entry) if len(matches) >= max_results: return matches, True - return matches, False + return matches, output.truncated def grep( self, @@ -360,13 +360,13 @@ class BoxliteBox(Sandbox): search = "grep " + " ".join(flags) + f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null" r = self._sh(remote_search_command(search, resolved, limit=total_cap)) # A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376). - output = parse_remote_search_output(r.stdout, resolved, tool="grep") + output = parse_remote_search_output(r.stdout, resolved, tool="grep", limit=total_cap) root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" matches: list[GrepMatch] = [] - truncated = False - for raw in output.splitlines(): + truncated = output.truncated + for raw in output.text.splitlines(): try: file_path, line_no_str, line_text = raw.split(":", 2) except ValueError: diff --git a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py index 8977d2ea2..5f87d1b02 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -420,12 +420,12 @@ class E2BSandbox(Sandbox): logger.error("Failed to glob in e2b sandbox: %s", e) raise OSError(f"Failed to glob {resolved} in e2b sandbox: {e}") from e # A missing root or a failed find must not read as "no files matched" (#5376). - output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="find") + output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="find", limit=hard_limit) matches: list[str] = [] root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" - for entry in output.splitlines(): + for entry in output.text.splitlines(): # Do NOT strip: trailing whitespace can be part of the filename. if not entry: continue @@ -440,7 +440,7 @@ class E2BSandbox(Sandbox): matches.append(entry) if len(matches) >= max_results: return matches, True - return matches, False + return matches, output.truncated def grep( self, @@ -493,14 +493,14 @@ class E2BSandbox(Sandbox): logger.error("Failed to grep in e2b sandbox: %s", e) raise OSError(f"Failed to grep {resolved} in e2b sandbox: {e}") from e # A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376). - output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="grep") + output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="grep", limit=total_cap) root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" matches: list[GrepMatch] = [] - truncated = False - for raw in output.splitlines(): + truncated = output.truncated + for raw in output.text.splitlines(): try: file_path, line_no_str, line_text = raw.split(":", 2) except ValueError: diff --git a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py index 54d1dc81f..64607c5f1 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py +++ b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py @@ -348,12 +348,12 @@ class OpenSandboxSandbox(Sandbox): search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null" execution = self._run(remote_search_command(search, resolved, limit=hard_limit)) # A missing root or a failed find must not read as "no files matched" (#5376). - output = parse_remote_search_output(execution_stdout(execution), resolved, tool="find") + output = parse_remote_search_output(execution_stdout(execution), resolved, tool="find", limit=hard_limit) matches: list[str] = [] root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" - for entry in output.splitlines(): + for entry in output.text.splitlines(): # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)) or should_ignore_path(entry): continue @@ -362,7 +362,7 @@ class OpenSandboxSandbox(Sandbox): matches.append(entry) if len(matches) >= max_results: return matches, True - return matches, False + return matches, output.truncated def grep( self, @@ -398,13 +398,13 @@ class OpenSandboxSandbox(Sandbox): # (127) is not reported as "no matches" (#5376). search = f'{primary}; status=$?; if [ "$status" -eq 2 ]; then {fallback}; status=$?; fi; (exit "$status")' execution = self._run(remote_search_command(search, resolved, limit=hard_limit)) - output = parse_remote_search_output(execution_stdout(execution), resolved, tool="grep") + output = parse_remote_search_output(execution_stdout(execution), resolved, tool="grep", limit=hard_limit) root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" matches: list[GrepMatch] = [] seen_positions: set[tuple[str, int]] = set() - for raw in output.splitlines(): + for raw in output.text.splitlines(): try: file_path, line_number_text, line = raw.split(":", 2) line_number = int(line_number_text) @@ -425,7 +425,7 @@ class OpenSandboxSandbox(Sandbox): matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line))) if len(matches) >= max_results: return matches, True - return matches, False + return matches, output.truncated def ping(self, timeout: float = 10) -> bool: if self.is_closed: diff --git a/backend/packages/harness/deerflow/community/tenki/sandbox.py b/backend/packages/harness/deerflow/community/tenki/sandbox.py index 2fbe48dde..a059bb8f6 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -394,12 +394,12 @@ class TenkiSandbox(Sandbox): search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null" r = self._sh(remote_search_command(search, resolved, limit=hard_limit)) # A missing root or a failed find must not read as "no files matched" (#5376). - output = parse_remote_search_output(r.stdout_text, resolved, tool="find") + output = parse_remote_search_output(r.stdout_text, resolved, tool="find", limit=hard_limit) matches: list[str] = [] root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" - for entry in output.splitlines(): + for entry in output.text.splitlines(): # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)): continue @@ -412,7 +412,7 @@ class TenkiSandbox(Sandbox): matches.append(self._virtual_path(entry)) if len(matches) >= max_results: return matches, True - return matches, False + return matches, output.truncated def grep( self, @@ -445,13 +445,13 @@ class TenkiSandbox(Sandbox): search = "grep " + " ".join(flags) + f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null" r = self._sh(remote_search_command(search, resolved, limit=total_cap)) # A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376). - output = parse_remote_search_output(r.stdout_text, resolved, tool="grep") + output = parse_remote_search_output(r.stdout_text, resolved, tool="grep", limit=total_cap) root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" matches: list[GrepMatch] = [] - truncated = False - for raw in output.splitlines(): + truncated = output.truncated + for raw in output.text.splitlines(): try: file_path, line_no_str, line_text = raw.split(":", 2) except ValueError: diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index ad4eb7fda..3f14118ec 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -1,6 +1,6 @@ ### Sandbox System (`packages/harness/deerflow/sandbox/`) -**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. Remote `grep(glob=...)` scopes like `glob()` (root-relative `path_matches`), never by basename alone. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success. +**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. The parser takes the command's `limit` and reports `truncated` when output passed it, which providers return after Python-side filtering; tools call an empty truncated result incomplete. Remote `grep(glob=...)` scopes like `glob()` (root-relative `path_matches`), never by basename alone. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success. **Provider Pattern**: `SandboxProvider` exposes `acquire`, `acquire_async`, `get`, `release`. Async agent/tool paths use async hooks to keep Docker creation, discovery, cross-process locking, readiness polling, and release off-loop. Set `supports_agent_skill_isolation=True` only when the whole tool surface enforces explicit lead Agent policy: bind mounts use prepared thread roots; upload providers implement `sync_agent_skills`. Host-backed providers report false if an enabled shell bypasses path mappings. Under explicit policy, middleware rejects unsupported providers before acquire. **Shared components** (RFC #4741): remote IDs use `derive_sandbox_scope_token` (`sandbox/identity.py`); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. `AcquireSerializer` (`sandbox/acquire_serialization.py`) serializes selected acquire/release transitions with a bounded, refcounted per-key `threading.Lock` table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider `shutdown()`/`reset()` calls idempotent `close()`. Keys: AIO `(user_id, thread_id)`, E2B `(user_id, thread_id, skills_root)`, BoxLite/Tenki/OpenSandbox derived id. Random-UUID `thread_id=None` acquires bypass serialization. **Execution leases** (`sandbox/lease.py`, #5128): cross-instance ownership decides which Gateway may reap a container; process-local `SandboxLeaseManager` tracks concurrent lead, subagent, Gateway-request, and channel-upload users of one client. Runs get ephemeral owners, persisted sandboxes are retained idempotently, and the last holder performs any pending `SandboxProvider.release`. Outer lifecycle fences repeat idempotent release after their complete graph/tool/request batch drains; per-tool terminal `Command` wrappers never release because sibling handlers may still run. Fork-restored children and upload syncs use non-releasing holders: they fence the client and own scope cleanup without themselves requesting a park; an earlier normal-owner request waits for them, and a missing fork client is replaced by a normal owner. Persisted lookup plus retention is serialized per `(user_id, thread_id)`; stale bindings fall through to acquire, and a post-acquire lookup miss rolls back before raising. Provider I/O does not hold the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let a `to_thread` sandbox operation outlive its enclosing execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway and worker scrub caller values; only the internal subagent path assigns a task ID. Managers are registered by provider object identity, not hash/equality, so unhashable custom providers remain valid. Subagent owners also serve as `sandbox_command_scope_id`: AIO gives each scope one ordered persistent shell session, replaces it after `ErrorObservation`, and cleans it on lease release. Registry identity is revalidated after every scope-lock wait, preventing queued commands from resurrecting released sessions. Env-bearing commands use fresh `bash.exec` sessions so secrets do not persist. diff --git a/backend/packages/harness/deerflow/sandbox/remote_search.py b/backend/packages/harness/deerflow/sandbox/remote_search.py index 9fd96f2f2..908f23204 100644 --- a/backend/packages/harness/deerflow/sandbox/remote_search.py +++ b/backend/packages/harness/deerflow/sandbox/remote_search.py @@ -10,12 +10,17 @@ nothing and exited 0, exactly like a genuine "no matches" (#5376). command's own status after the bounded output, the same technique as :mod:`deerflow.sandbox.remote_list_dir`. The script always exits 0 so SDKs that raise on a non-zero exit still return the marker; the marker alone decides. + +Callers filter the bounded lines in Python (ignored directories, glob scope), +so returning fewer than ``max_results`` does not prove the search was complete. +The command lets one line past ``limit`` through, and the parser reports +whether it arrived; exactly ``limit`` lines is a complete result. """ from __future__ import annotations import shlex -from typing import Literal +from typing import Literal, NamedTuple SearchTool = Literal["grep", "find"] @@ -33,13 +38,22 @@ _OK_STATUSES: dict[str, tuple[int, ...]] = {"grep": (0, 1, _SIGPIPE), "find": (0 _READ_ERROR_STATUS: dict[str, int] = {"grep": 2, "find": 1} +class RemoteSearchOutput(NamedTuple): + """Search output lines, and whether the search produced more than ``limit``.""" + + text: str + truncated: bool + + def remote_search_command(search: str, root: str, *, limit: int) -> str: """Wrap a ``grep``/``find`` command so its outcome survives ``| head``. ``search`` must write only results to stdout; callers keep ``2>/dev/null``. + Pass the same ``limit`` to :func:`parse_remote_search_output`. """ quoted = shlex.quote(root) - n = int(limit) + # One extra line is the truncation signal; the parser drops it. + n = int(limit) + 1 return ( f"set +e; if [ ! -e {quoted} ]; then printf '%s\\n' {_STATUS_PREFIX}{_MISSING_ROOT}; exit 0; fi; " f'_st=/tmp/df_search_$$; {{ {search}; echo $? > "$_st"; }} | head -n {n}; ' @@ -48,8 +62,11 @@ def remote_search_command(search: str, root: str, *, limit: int) -> str: ) -def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool) -> str: - """Return the search output without the status marker. +def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool, limit: int) -> RemoteSearchOutput: + """Return at most ``limit`` output lines without the status marker. + + ``truncated`` is true when the search printed more than ``limit`` lines, so + results filtered from ``text`` may be incomplete. Raises: FileNotFoundError: The search root does not exist. @@ -73,7 +90,7 @@ def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchToo except ValueError: raise OSError(f"Failed to {tool} under {root}: search status unavailable") from None if status in _OK_STATUSES[tool]: - return "\n".join(lines) + return RemoteSearchOutput("\n".join(lines[:limit]), len(lines) > limit) if status == _READ_ERROR_STATUS[tool]: raise OSError(f"Failed to {tool} under {root}: {tool} exited with code {status}, usually because some files or directories could not be read; results would be incomplete, so search a narrower path") raise OSError(f"Failed to {tool} under {root}: command exited with code {status}") diff --git a/backend/packages/harness/deerflow/sandbox/sandbox.py b/backend/packages/harness/deerflow/sandbox/sandbox.py index ab69c0a2d..dca0c5e64 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox.py @@ -200,7 +200,12 @@ class Sandbox(ABC): @abstractmethod def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: - """Find paths that match a glob pattern under a root directory.""" + """Find paths that match a glob pattern under a root directory. + + Returns the matches and ``truncated``, which is true whenever the + matches may be incomplete: ``max_results`` was reached, or the search + stopped at an output cap before filtering. + """ pass @abstractmethod @@ -214,7 +219,11 @@ class Sandbox(ABC): case_sensitive: bool = False, max_results: int = 100, ) -> tuple[list[GrepMatch], bool]: - """Search for matches inside a text file or files under a directory.""" + """Search for matches inside a text file or files under a directory. + + Returns the matches and ``truncated``, with the same meaning as in + :meth:`glob`. + """ pass @abstractmethod diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 63628fa0c..4748af32b 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -604,6 +604,10 @@ def _resolve_local_read_path(path: str, thread_data: ThreadDataState | None) -> def _format_glob_results(root_path: str, matches: list[str], truncated: bool) -> str: if not matches: + # A remote search can hit its output cap before any path survives the + # Python-side filters; that is not evidence that nothing matches. + if truncated: + return f"Search under {root_path} stopped at its result limit with no files matched in the part it covered; results are incomplete. Narrow the path or pattern." return f"No files matched under {root_path}" lines = [f"Found {len(matches)} paths under {root_path}"] @@ -617,6 +621,8 @@ def _format_glob_results(root_path: str, matches: list[str], truncated: bool) -> def _format_grep_results(root_path: str, matches: list[GrepMatch], truncated: bool) -> str: if not matches: + if truncated: + return f"Search under {root_path} stopped at its result limit with no matches in the part it covered; results are incomplete. Narrow the path or add a glob filter." return f"No matches found under {root_path}" lines = [f"Found {len(matches)} matches under {root_path}"] diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index ff8790b31..67bef80c3 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -1429,6 +1429,26 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path, monkeyp assert box.glob(str(tmp_path), "*.md") == ([], False) +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)]) +def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 caps the raw stream at 51 lines, and every line falls outside + # the glob, so nothing survives the Python-side filter. Only the cap decides + # whether that empty result is complete; reporting it as such reads as "no + # matches" while an in-scope file may sit past the cap. + (tmp_path / "other").mkdir() + for index in range(entries): + (tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + result = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert result == ([], truncated) + + @_RS_POSIX def test_grep_glob_keeps_its_directory_prefix(tmp_path, monkeypatch) -> None: # grep has no portable --include, so the glob is applied in Python. Matching diff --git a/backend/tests/test_e2b_sandbox_provider.py b/backend/tests/test_e2b_sandbox_provider.py index 4066f645f..6932b7b2f 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -5409,6 +5409,26 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path): assert sb.glob(str(tmp_path), "*.md") == ([], False) +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)]) +def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, op, entries, truncated) -> None: + # max_results=1 caps the raw stream at 51 lines, and every line falls outside + # the glob, so nothing survives the Python-side filter. Only the cap decides + # whether that empty result is complete; reporting it as such reads as "no + # matches" while an in-scope file may sit past the cap. + (tmp_path / "other").mkdir() + for index in range(entries): + (tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + sb = _rs_sandbox(tmp_path) + + if op == "grep": + result = sb.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + result = sb.glob(str(tmp_path), "src/*.js", max_results=1) + + assert result == ([], truncated) + + @pytest.mark.parametrize("op", ["grep", "glob"]) def test_remote_search_raises_when_the_client_call_fails(op): sb = _make_sandbox(FakeClient(commands=FakeCommandsAPI([FakeCommandsAPI.GONE]))) diff --git a/backend/tests/test_opensandbox_provider.py b/backend/tests/test_opensandbox_provider.py index 2432095f3..55a4425c0 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -880,3 +880,23 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path, monkeyp found, _ = box.glob(str(tmp_path), "**/*.py") assert [os.path.basename(path) for path in found] == ["app.py"] assert box.glob(str(tmp_path), "*.md") == ([], False) + + +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)]) +def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 caps the raw stream at 51 lines, and every line falls outside + # the glob, so nothing survives the Python-side filter. Only the cap decides + # whether that empty result is complete; reporting it as such reads as "no + # matches" while an in-scope file may sit past the cap. + (tmp_path / "other").mkdir() + for index in range(entries): + (tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + result = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert result == ([], truncated) diff --git a/backend/tests/test_remote_search.py b/backend/tests/test_remote_search.py index a2652090f..d16d8d130 100644 --- a/backend/tests/test_remote_search.py +++ b/backend/tests/test_remote_search.py @@ -54,30 +54,30 @@ def _env_with_fake(tmp_path, name: str, script: str) -> dict[str, str]: def test_parse_missing_root_marker_is_file_not_found() -> None: with pytest.raises(FileNotFoundError): - parse_remote_search_output("__DF_SEARCH_STATUS__:missing\n", "/dir", tool="grep") + parse_remote_search_output("__DF_SEARCH_STATUS__:missing\n", "/dir", tool="grep", limit=450) @pytest.mark.parametrize("stdout", ["", "/dir/a.py:1:needle\n"]) def test_parse_without_marker_is_a_failure_not_a_result(stdout: str) -> None: with pytest.raises(OSError, match="status marker missing"): - parse_remote_search_output(stdout, "/dir", tool="grep") + parse_remote_search_output(stdout, "/dir", tool="grep", limit=450) @pytest.mark.parametrize(("tool", "status"), [("grep", 0), ("grep", 141), ("find", 0), ("find", 141)]) def test_parse_success_keeps_output_and_trailing_space(tool: str, status: int) -> None: stdout = f"/dir/notes.txt \n\n__DF_SEARCH_STATUS__:{status}\n" - assert parse_remote_search_output(stdout, "/dir", tool=tool) == "/dir/notes.txt " + assert parse_remote_search_output(stdout, "/dir", tool=tool, limit=450) == ("/dir/notes.txt ", False) def test_parse_genuine_no_match_is_empty() -> None: - assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:1\n", "/dir", tool="grep") == "" - assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find") == "" + assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:1\n", "/dir", tool="grep", limit=450) == ("", False) + assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=850) == ("", False) @pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("grep", 126), ("grep", 127), ("find", 1), ("find", 127)]) def test_parse_failure_without_output_raises(tool: str, status: int) -> None: with pytest.raises(OSError, match=f"exited with code {status}"): - parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool) + parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450) @pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("find", 1)]) @@ -86,7 +86,7 @@ def test_parse_error_after_partial_output_still_raises(tool: str, status: int) - # have no partial-result channel: it must not pass as a complete search, and # the error must tell the agent how to recover. with pytest.raises(OSError, match=f"exited with code {status}") as info: - parse_remote_search_output(f"/dir/a.py\n\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool) + parse_remote_search_output(f"/dir/a.py\n\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450) assert "could not be read" in str(info.value) assert "narrower path" in str(info.value) @@ -94,21 +94,33 @@ def test_parse_error_after_partial_output_still_raises(tool: str, status: int) - @pytest.mark.parametrize(("tool", "status"), [("grep", 126), ("grep", 127), ("find", 127)]) def test_parse_other_failures_do_not_blame_unreadable_paths(tool: str, status: int) -> None: with pytest.raises(OSError, match=f"exited with code {status}") as info: - parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool) + parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450) assert "could not be read" not in str(info.value) def test_parse_unparseable_status_is_a_failure() -> None: with pytest.raises(OSError, match="status unavailable"): - parse_remote_search_output("\n__DF_SEARCH_STATUS__:\n", "/dir", tool="find") + parse_remote_search_output("\n__DF_SEARCH_STATUS__:\n", "/dir", tool="find", limit=850) + + +@pytest.mark.parametrize(("lines", "truncated"), [(0, False), (3, False), (4, True)]) +def test_parse_reports_truncation_only_when_output_passes_the_limit(lines: int, truncated: bool) -> None: + """Callers filter these lines in Python, so fewer results than ``max_results`` + no longer proves the search was complete; only the extra line does. Exactly + ``limit`` lines is a complete result, not a truncated one.""" + body = "".join(f"/dir/f{index}.py\n" for index in range(lines)) + result = parse_remote_search_output(f"{body}\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=3) + assert result.text == "\n".join(f"/dir/f{index}.py" for index in range(min(lines, 3))) + assert result.truncated is truncated def test_command_checks_root_first_and_records_status_after_head() -> None: command = remote_search_command(_grep("/mnt/data dir"), "/mnt/data dir", limit=450) assert command.startswith("set +e; ") assert "[ ! -e '/mnt/data dir' ]" in command - assert command.index("[ ! -e ") < command.index("grep ") < command.index("head -n 450") - assert command.index("head -n 450") < command.rindex("__DF_SEARCH_STATUS__:") + # One line past the limit is what lets the parser tell a full result from a cut one. + assert command.index("[ ! -e ") < command.index("grep ") < command.index("head -n 451") + assert command.index("head -n 451") < command.rindex("__DF_SEARCH_STATUS__:") assert command.endswith("exit 0") @@ -130,14 +142,14 @@ def test_grep_distinguishes_match_no_match_and_missing_root(tmp_path) -> None: (tmp_path / "src" / "app.py").write_text("def needle():\n return 1\n", encoding="utf-8") root = str(tmp_path) - found = parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep") - assert found == f"{tmp_path / 'src' / 'app.py'}:1:def needle():" - none = parse_remote_search_output(_run(remote_search_command(_grep(root, "zzz_nothing"), root, limit=450)), root, tool="grep") - assert none == "" + found = parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450) + assert found == (f"{tmp_path / 'src' / 'app.py'}:1:def needle():", False) + none = parse_remote_search_output(_run(remote_search_command(_grep(root, "zzz_nothing"), root, limit=450)), root, tool="grep", limit=450) + assert none == ("", False) missing = str(tmp_path / "missing") with pytest.raises(FileNotFoundError): - parse_remote_search_output(_run(remote_search_command(_grep(missing), missing, limit=450)), missing, tool="grep") + parse_remote_search_output(_run(remote_search_command(_grep(missing), missing, limit=450)), missing, tool="grep", limit=450) @_POSIX_SH @@ -147,7 +159,7 @@ def test_missing_search_binary_is_a_failure_not_a_no_match(tmp_path, binary: str root = str(tmp_path) search, tool = (_grep(root), "grep") if binary == "grep" else (_find(root), "find") with pytest.raises(OSError, match="exited with code 127"): - parse_remote_search_output(_run(remote_search_command(search, root, limit=450), env=env), root, tool=tool) + parse_remote_search_output(_run(remote_search_command(search, root, limit=450), env=env), root, tool=tool, limit=450) @_POSIX_SH @@ -158,12 +170,12 @@ def test_find_follows_a_symlinked_root_and_reports_an_empty_tree(tmp_path) -> No (real / "sub" / "a.txt").write_text("x", encoding="utf-8") link = tmp_path / "link" link.symlink_to(real, target_is_directory=True) - out = parse_remote_search_output(_run(remote_search_command(_find(str(link)), str(link), limit=850)), str(link), tool="find") - assert out == f"{link}/sub/a.txt" + out = parse_remote_search_output(_run(remote_search_command(_find(str(link)), str(link), limit=850)), str(link), tool="find", limit=850) + assert out == (f"{link}/sub/a.txt", False) empty = tmp_path / "empty" empty.mkdir() - assert parse_remote_search_output(_run(remote_search_command(_find(str(empty)), str(empty), limit=850)), str(empty), tool="find") == "" + assert parse_remote_search_output(_run(remote_search_command(_find(str(empty)), str(empty), limit=850)), str(empty), tool="find", limit=850) == ("", False) @_POSIX_SH @@ -176,7 +188,7 @@ def test_unreadable_root_is_a_failure_not_a_no_match(tmp_path) -> None: locked.chmod(0) try: with pytest.raises(OSError, match="exited with code 2"): - parse_remote_search_output(_run(remote_search_command(_grep(str(locked)), str(locked), limit=450)), str(locked), tool="grep") + parse_remote_search_output(_run(remote_search_command(_grep(str(locked)), str(locked), limit=450)), str(locked), tool="grep", limit=450) finally: locked.chmod(0o700) @@ -196,17 +208,31 @@ def test_unreadable_subtree_is_a_failure_not_a_partial_result(tmp_path) -> None: try: # Both searches print the readable match before failing on the locked subtree. with pytest.raises(OSError, match="exited with code 2.*could not be read"): - parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep") + parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450) with pytest.raises(OSError, match="exited with code 1.*could not be read"): - parse_remote_search_output(_run(remote_search_command(_find(root), root, limit=850)), root, tool="find") + parse_remote_search_output(_run(remote_search_command(_find(root), root, limit=850)), root, tool="find", limit=850) finally: locked.chmod(0o700) +def _counting_grep(count: int) -> str: + return f'#!/bin/sh\ni=1\nwhile [ "$i" -le {count} ]; do\n echo "/dir/f$i:1:needle"\n i=$((i+1))\ndone\nexit 0\n' + + @_POSIX_SH def test_head_truncation_is_not_a_failure(tmp_path) -> None: - env = _env_with_fake(tmp_path, "grep", '#!/bin/sh\ni=1\nwhile [ "$i" -le 5000 ]; do\n echo "/dir/f$i:1:needle"\n i=$((i+1))\ndone\nexit 0\n') - out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep") - lines = out.split("\n") + env = _env_with_fake(tmp_path, "grep", _counting_grep(5000)) + out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450) + lines = out.text.split("\n") assert len(lines) == 450 assert lines[0] == "/dir/f1:1:needle" + assert out.truncated is True + + +@_POSIX_SH +@pytest.mark.parametrize(("count", "truncated"), [(450, False), (451, True)]) +def test_real_pipeline_reports_truncation_at_the_limit_boundary(tmp_path, count: int, truncated: bool) -> None: + env = _env_with_fake(tmp_path, "grep", _counting_grep(count)) + out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450) + assert len(out.text.split("\n")) == 450 + assert out.truncated is truncated diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index be1af3f34..61f3dc97e 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -2,6 +2,7 @@ import json from types import SimpleNamespace from unittest.mock import patch +import pytest from support.symlinks import symlink_or_skip from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox @@ -123,6 +124,40 @@ def test_grep_tool_accepts_single_file_path(tmp_path, monkeypatch) -> None: assert str(uploads) not in result +def _remote_search_runtime(): + return SimpleNamespace(state={"sandbox": {"sandbox_id": "remote-1"}}, context={"thread_id": "thread-1"}) + + +@pytest.mark.parametrize( + ("tool", "arguments"), + [ + (glob_tool, {"pattern": "src/*.py"}), + (grep_tool, {"pattern": "needle", "glob": "src/*.py"}), + ], +) +def test_search_tools_do_not_report_a_truncated_empty_result_as_no_matches(monkeypatch, tool, arguments) -> None: + """A remote search whose output hit its cap before any line survived the glob + filter has no results to show, but it has not proven there are none.""" + sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], True), grep=lambda *args, **kwargs: ([], True)) + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox) + + result = tool.func(runtime=_remote_search_runtime(), description="scoped search", path="/mnt/user-data/workspace", **arguments) + + assert not result.startswith(("No files matched", "No matches found")) + assert "incomplete" in result + assert "/mnt/user-data/workspace" in result + + +@pytest.mark.parametrize(("tool", "arguments", "expected"), [(glob_tool, {"pattern": "*.py"}, "No files matched under"), (grep_tool, {"pattern": "needle"}, "No matches found under")]) +def test_search_tools_keep_the_no_match_message_for_a_complete_empty_result(monkeypatch, tool, arguments, expected) -> None: + sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], False), grep=lambda *args, **kwargs: ([], False)) + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox) + + result = tool.func(runtime=_remote_search_runtime(), description="search", path="/mnt/user-data/workspace", **arguments) + + assert result == f"{expected} /mnt/user-data/workspace" + + def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None: runtime = _make_runtime(tmp_path) workspace = tmp_path / "workspace" diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index df0c32da8..7a6e8e81e 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -1138,3 +1138,23 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path, monkeyp found, _ = box.glob(str(tmp_path), "**/*.py") assert [os.path.basename(path) for path in found] == ["app.py"] assert box.glob(str(tmp_path), "*.md") == ([], False) + + +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)]) +def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 caps the raw stream at 51 lines, and every line falls outside + # the glob, so nothing survives the Python-side filter. Only the cap decides + # whether that empty result is complete; reporting it as such reads as "no + # matches" while an in-scope file may sit past the cap. + (tmp_path / "other").mkdir() + for index in range(entries): + (tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + result = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert result == ([], truncated)