fix(sandbox): report an exactly-full search result as complete in the remote providers (#5534)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
FanouZeng-TT 2026-09-20 14:34:37 +08:00 committed by GitHub
parent 03505ac4e0
commit 492e2ac2cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 216 additions and 25 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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])))

View File

@ -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

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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