From 492e2ac2cc53b038fb878489553d1bf9279e8abe Mon Sep 17 00:00:00 2001 From: FanouZeng-TT <124567600+FanouZeng-TT@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:34:37 +0800 Subject: [PATCH] fix(sandbox): report an exactly-full search result as complete in the remote providers (#5534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sandbox): report an exactly-full search result as complete in the remote providers `glob` and `grep` decide `truncated` twice: once for the raw output cap (`parse_remote_search_output`, unchanged) and once for `max_results` after the Python-side filters have run. The second decision returned as soon as `max_results` matches had been collected, which cannot tell a search that held exactly that many from one that held more — a tree holding exactly `max_results` eligible matches came back flagged as cut off, and the tool then told the model the result was incomplete. These providers hold the whole listing (the raw stream is capped at `max(max_results * 4, max_results + 50)` lines and reports its own cut-off), so like AIO's `glob` branches they can look one match past the cap before deciding: `AioSandbox.grep`, plus `glob`/`grep` in E2B, OpenSandbox, Tenki and BoxLite now use the same `len(matches) > max_results` rule. This completes what #5449 started for AIO's `glob`; the local provider's half is #5491. Co-Authored-By: Claude Code * fix(sandbox): let remote grep see one match past the per-file cap E2B and OpenSandbox stopped each file's grep at max(max_results, 50) matches, so a single file holding more than max_results hits — with a raw stream far below its limit — ended the Python loop exactly at the cap and reported the result as complete (#5534 review). Retain one extra match per file so the one-match lookahead can observe the overflow and report truncation. A single-file regression at max_results=50 covers 50 matches (complete) vs 51 (truncated) for both providers. Co-Authored-By: Claude Code --------- Co-authored-by: Claude Code Co-authored-by: Willem Jiang --- CHANGELOG.md | 10 +++++ CHANGELOG_zh.md | 7 ++++ .../community/aio_sandbox/aio_sandbox.py | 9 +++-- .../harness/deerflow/community/boxlite/box.py | 14 ++++--- .../community/e2b_sandbox/e2b_sandbox.py | 16 +++++--- .../deerflow/community/opensandbox/sandbox.py | 15 ++++--- .../deerflow/community/tenki/sandbox.py | 14 ++++--- backend/tests/test_boxlite_provider.py | 21 ++++++++++ backend/tests/test_e2b_sandbox_provider.py | 36 +++++++++++++++++ backend/tests/test_opensandbox_provider.py | 38 +++++++++++++++++- backend/tests/test_sandbox_search_tools.py | 40 +++++++++++++++++++ backend/tests/test_tenki_provider.py | 21 ++++++++++ 12 files changed, 216 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e96cf82c5..17ead4193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1018,6 +1018,15 @@ This release closes that milestone with **765 merged pull requests**. filtered-match cap only: the raw-output cap `parse_remote_search_output` owns is a separate limit with its own one-line-past accounting, and the other providers' filtered-match cap is unchanged. ([#5449]) +- **sandbox:** Stop AIO's `grep` and the remote providers' `glob`/`grep` from + reporting an exactly-full result as truncated. They hold the whole listing — + the raw stream is capped above `max_results` and reports its own cut-off — but + they returned as soon as they had collected `max_results` filtered matches, so + a tree holding exactly that many — and no more — came back flagged as cut off + and the tool told the model the result was incomplete. They now look one match + past the cap before deciding, the rule AIO's `glob` branches already apply. + This concerns the filtered-match cap only; the raw-output cap + `parse_remote_search_output` owns is unchanged. ([#5534]) - **middleware:** Stop a guard that removes tool calls from breaking every later turn of a Claude or OpenAI Responses thread. Token-budget and loop-detection hard stops, subagent-limit truncation, and safety suppression cleared @@ -4299,4 +4308,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5505]: https://github.com/bytedance/deer-flow/pull/5505 [#5524]: https://github.com/bytedance/deer-flow/pull/5524 [#5526]: https://github.com/bytedance/deer-flow/pull/5526 +[#5534]: https://github.com/bytedance/deer-flow/pull/5534 [#5547]: https://github.com/bytedance/deer-flow/pull/5547 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index f0e348891..eec703efd 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -798,6 +798,12 @@ 与同一函数的 `include_dirs=False` 分支一致(后者一直是按完整列表判断的)。这里涉及的只是 **过滤后匹配数**上限;`parse_remote_search_output` 管的是**原始输出行数**上限,是另一条限制、 有自己的"多放一行"记账方式,其他 provider 的过滤后匹配数上限未作改动。 +- **沙箱:** AIO 的 `grep` 与各远端 provider 的 `glob`/`grep` 不再把"恰好填满"的结果报告为截断。 + 它们本就持有整份列表——原始输出在上限之上截取并自行报告截断——但在收集到 `max_results` 个 + 过滤后的匹配时就立即返回,因此一个只有这么多匹配、后面再无匹配的目录也会被标记为被截断, + 工具据此告诉模型结果不完整。现在改为多看一个匹配再判断,与 AIO 的 `glob` 两个分支一致。 + 这里涉及的只是**过滤后匹配数**上限;`parse_remote_search_output` 管的**原始输出行数**上限未作改动。 + ([#5534]) - **中间件:** 移除工具调用的守卫不再导致 Claude 或 OpenAI Responses 线程之后的每一轮都失败。 token 预算与循环检测的硬停止、subagent 数量限制的截断以及安全终止抑制只清空了 `tool_calls`, 却把 provider 自身的工具调用块留在消息 content 中。Anthropic 与 Responses API 会重新发送这些块, @@ -3504,4 +3510,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5505]: https://github.com/bytedance/deer-flow/pull/5505 [#5524]: https://github.com/bytedance/deer-flow/pull/5524 [#5526]: https://github.com/bytedance/deer-flow/pull/5526 +[#5534]: https://github.com/bytedance/deer-flow/pull/5534 [#5547]: https://github.com/bytedance/deer-flow/pull/5547 diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index b2d7234e4..387780dd5 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -757,9 +757,12 @@ class AioSandbox(Sandbox): line=truncate_line(match.line_content), ) ) - if len(matches) >= max_results: - truncated = True - break + # Look one match past the cap before deciding, as ``glob`` above + # does. Returning on the ``max_results``-th match cannot tell a + # search that held exactly that many from one that held more, so an + # exhausted search was reported as truncated. + if len(matches) > max_results: + return matches[:max_results], True return matches, truncated diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index 6664d1379..1850c8aa7 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -327,8 +327,12 @@ class BoxliteBox(Sandbox): continue if path_matches(pattern, rel_path): matches.append(entry) - if len(matches) >= max_results: - return matches, True + # Look one match past the cap before deciding: returning on the + # max-th match cannot tell a search that held exactly + # ``max_results`` from one that held more, so an exhausted tree + # was reported as truncated. + if len(matches) > max_results: + return matches[:max_results], True return matches, output.truncated def grep( @@ -387,7 +391,7 @@ class BoxliteBox(Sandbox): if not path_matches(glob, rel_path): continue matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line_text))) - if len(matches) >= max_results: - truncated = True - break + # Same one-match-past-the-cap rule as glob() above. + if len(matches) > max_results: + return matches[:max_results], True return matches, truncated 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 5f87d1b02..429d64166 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -438,8 +438,12 @@ class E2BSandbox(Sandbox): continue if path_matches(pattern, rel_path): matches.append(entry) - if len(matches) >= max_results: - return matches, True + # Look one match past the cap before deciding: returning on the + # max-th match cannot tell a search that held exactly + # ``max_results`` from one that held more, so an exhausted tree + # was reported as truncated. + if len(matches) > max_results: + return matches[:max_results], True return matches, output.truncated def grep( @@ -477,7 +481,7 @@ class E2BSandbox(Sandbox): include_pattern = glob.split("/")[-1] or glob flags.append(f"--include={include_pattern}") - per_file_cap = max(max_results, 50) + per_file_cap = max(max_results + 1, 50) total_cap = max(max_results * 4, max_results + 50) flags.append(f"-m{per_file_cap}") @@ -526,7 +530,7 @@ class E2BSandbox(Sandbox): line=truncate_line(line_text), ) ) - if len(matches) >= max_results: - truncated = True - break + # Same one-match-past-the-cap rule as glob() above. + if len(matches) > max_results: + return matches[:max_results], True return matches, truncated diff --git a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py index 64607c5f1..643a0722c 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py +++ b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py @@ -360,8 +360,12 @@ class OpenSandboxSandbox(Sandbox): relative = entry[len(root) :].lstrip("/") if relative and path_matches(pattern, relative): matches.append(entry) - if len(matches) >= max_results: - return matches, True + # Look one match past the cap before deciding: returning on the + # max-th match cannot tell a search that held exactly + # ``max_results`` from one that held more, so an exhausted tree + # was reported as truncated. + if len(matches) > max_results: + return matches[:max_results], True return matches, output.truncated def grep( @@ -387,7 +391,7 @@ class OpenSandboxSandbox(Sandbox): if glob is not None: include_pattern = glob.split("/")[-1] or glob flags.append(shlex.quote(f"--include={include_pattern}")) - per_file_cap = max(max_results, 50) + per_file_cap = max(max_results + 1, 50) flags.append(f"-m{per_file_cap}") hard_limit = max(max_results * 4, max_results + 50) arguments = f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null" @@ -423,8 +427,9 @@ class OpenSandboxSandbox(Sandbox): continue seen_positions.add(position) matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line))) - if len(matches) >= max_results: - return matches, True + # Same one-match-past-the-cap rule as glob() above. + if len(matches) > max_results: + return matches[:max_results], True return matches, output.truncated def ping(self, timeout: float = 10) -> bool: diff --git a/backend/packages/harness/deerflow/community/tenki/sandbox.py b/backend/packages/harness/deerflow/community/tenki/sandbox.py index a059bb8f6..e1b1a926a 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -410,8 +410,12 @@ class TenkiSandbox(Sandbox): continue if path_matches(pattern, rel_path): matches.append(self._virtual_path(entry)) - if len(matches) >= max_results: - return matches, True + # Look one match past the cap before deciding: returning on the + # max-th match cannot tell a search that held exactly + # ``max_results`` from one that held more, so an exhausted tree + # was reported as truncated. + if len(matches) > max_results: + return matches[:max_results], True return matches, output.truncated def grep( @@ -472,9 +476,9 @@ class TenkiSandbox(Sandbox): 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: - truncated = True - break + # Same one-match-past-the-cap rule as glob() above. + if len(matches) > max_results: + return matches[:max_results], True return matches, truncated diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index 0ea75152b..a887db7af 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -1451,6 +1451,27 @@ def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tm assert result == ([], truncated) +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 1, False), ("grep", 2, True), ("glob", 1, False), ("glob", 2, True)]) +def test_remote_search_exactly_full_is_not_truncated(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 over a tree holding one in-scope match is a complete result: + # the Python-side loop used to return on the max-th match without looking for + # one more, so an exhausted search over a one-match tree read as cut off. A + # second match keeps that report honest. + (tmp_path / "src").mkdir() + for index in range(entries): + (tmp_path / "src" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + matches, reported = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + matches, reported = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert len(matches) == 1 + assert reported is 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 7e6dc68e9..c0c24e5a7 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -5429,6 +5429,42 @@ def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tm assert result == ([], truncated) +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 1, False), ("grep", 2, True), ("glob", 1, False), ("glob", 2, True)]) +def test_remote_search_exactly_full_is_not_truncated(tmp_path, op, entries, truncated) -> None: + # max_results=1 over a tree holding one in-scope match is a complete result: + # the Python-side loop used to return on the max-th match without looking for + # one more, so an exhausted search over a one-match tree read as cut off. A + # second match keeps that report honest. + (tmp_path / "src").mkdir() + for index in range(entries): + (tmp_path / "src" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + sb = _rs_sandbox(tmp_path) + + if op == "grep": + matches, reported = sb.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + matches, reported = sb.glob(str(tmp_path), "src/*.js", max_results=1) + + assert len(matches) == 1 + assert reported is truncated + + +@_RS_POSIX +@pytest.mark.parametrize(("entries", "truncated"), [(50, False), (51, True)]) +def test_remote_grep_reports_single_file_overflow(tmp_path, entries, truncated) -> None: + # The shell command must retain one more match per file than the caller's + # cap. Otherwise 51 matches in this single file look complete at a cap of + # 50 because the raw-output cap is not reached. + source = tmp_path / "src.py" + source.write_text("needle\n" * entries, encoding="utf-8") + + matches, reported = _rs_sandbox(tmp_path).grep(str(tmp_path), "needle", max_results=50) + + assert len(matches) == 50 + assert reported is 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 4f9d813af..a1e32c54d 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -639,7 +639,7 @@ def test_list_glob_and_grep_return_virtual_paths() -> None: assert truncated is False grep_tokens = shlex.split(remote.commands.calls[-1][0]) assert "--include=*.py" in grep_tokens - assert "-m100" in grep_tokens + assert "-m101" in grep_tokens box.grep("/mnt/user-data/workspace", "needle", glob="src/*.py; echo injected", literal=True) unsafe_glob_tokens = shlex.split(remote.commands.calls[-1][0]) @@ -900,3 +900,39 @@ def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tm result = box.glob(str(tmp_path), "src/*.js", max_results=1) assert result == ([], truncated) + + +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 1, False), ("grep", 2, True), ("glob", 1, False), ("glob", 2, True)]) +def test_remote_search_exactly_full_is_not_truncated(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 over a tree holding one in-scope match is a complete result: + # the Python-side loop used to return on the max-th match without looking for + # one more, so an exhausted search over a one-match tree read as cut off. A + # second match keeps that report honest. + (tmp_path / "src").mkdir() + for index in range(entries): + (tmp_path / "src" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + matches, reported = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + matches, reported = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert len(matches) == 1 + assert reported is truncated + + +@_RS_POSIX +@pytest.mark.parametrize(("entries", "truncated"), [(50, False), (51, True)]) +def test_remote_grep_reports_single_file_overflow(tmp_path, monkeypatch, entries, truncated) -> None: + # The shell command must retain one more match per file than the caller's + # cap. Otherwise 51 matches in this single file look complete at a cap of + # 50 because the raw-output cap is not reached. + source = tmp_path / "src.py" + source.write_text("needle\n" * entries, encoding="utf-8") + + matches, reported = _rs_box(tmp_path, monkeypatch).grep(str(tmp_path), "needle", max_results=50) + + assert len(matches) == 50 + assert reported is truncated diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index 8024b6d2d..4aebe116d 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -590,6 +590,46 @@ def test_aio_sandbox_grep_drops_matches_outside_requested_root(monkeypatch) -> N assert truncated is False +def _grep_files_returning(count: int): + """Provider reply holding ``count`` matching lines, none of them cut off.""" + return lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + matches=[SimpleNamespace(file=f"/mnt/user-data/workspace/f{index}.py", line_number=index + 1, line_content="TODO = True") for index in range(count)], + truncated=False, + ) + ) + + +def test_aio_sandbox_grep_exactly_full_is_not_truncated(monkeypatch) -> None: + """A grep whose matches exactly fill max_results is complete. + + The loop used to return as soon as it had collected ``max_results`` + matches, without looking at the remaining lines, so a search that found + exactly that many was reported as cut off even though every line was seen. + It now scans one eligible match past the cap, as the ``glob`` branches do. + """ + 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", _grep_files_returning(2)) + + matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO", max_results=2) + + assert [match.path for match in matches] == ["/mnt/user-data/workspace/f0.py", "/mnt/user-data/workspace/f1.py"] + assert truncated is False + + +def test_aio_sandbox_grep_reports_a_dropped_match_as_truncated(monkeypatch) -> None: + """The counterpart: a match past the cap still reports truncated.""" + 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", _grep_files_returning(3)) + + matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO", max_results=2) + + assert [match.path for match in matches] == ["/mnt/user-data/workspace/f0.py", "/mnt/user-data/workspace/f1.py"] + assert truncated is True + + # --------------------------------------------------------------------------- # ls_tool — path masking # --------------------------------------------------------------------------- diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index 3472d3c5d..8134b894f 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -1159,3 +1159,24 @@ def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tm result = box.glob(str(tmp_path), "src/*.js", max_results=1) assert result == ([], truncated) + + +@_RS_POSIX +@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 1, False), ("grep", 2, True), ("glob", 1, False), ("glob", 2, True)]) +def test_remote_search_exactly_full_is_not_truncated(tmp_path, monkeypatch, op, entries, truncated) -> None: + # max_results=1 over a tree holding one in-scope match is a complete result: + # the Python-side loop used to return on the max-th match without looking for + # one more, so an exhausted search over a one-match tree read as cut off. A + # second match keeps that report honest. + (tmp_path / "src").mkdir() + for index in range(entries): + (tmp_path / "src" / f"f{index}.js").write_text("needle\n", encoding="utf-8") + box = _rs_box(tmp_path, monkeypatch) + + if op == "grep": + matches, reported = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1) + else: + matches, reported = box.glob(str(tmp_path), "src/*.js", max_results=1) + + assert len(matches) == 1 + assert reported is truncated