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