fix(sandbox): report an exactly-full AIO glob result as complete (#5449)

* fix(sandbox): report an exactly-full AIO glob result as complete

AioSandbox.glob's include_dirs branch returned as soon as it had
collected max_results matches, without looking at the rest of the
listing. A listing that held exactly that many matches and nothing more
was therefore reported as truncated, and the glob tool told the model
the result was incomplete — prompting a re-search or distrust of a
complete answer. The same line returned one match for max_results=0,
one past the caller's cap.

Look one match past the cap before deciding, which is what the
include_dirs=False branch in the same function already does and what
#5427 moved parse_remote_search_output to for BoxLite, Tenki, E2B and
OpenSandbox.

* review: filtered-tail cases, the glob contract docstring, and the cap wording

Addresses the three items from the review on #5449.

- Two regression cases over a tail of ignored / out-of-root / pattern-miss
  entries: an exactly-full result stays complete when only filtered entries
  follow, and a third eligible match after that tail still reports
  truncation. Both fail against the previous return-on-the-max-th-match
  behaviour.
- 'Sandbox.glob' promised the conservative flag ('``max_results`` was
  reached') that this change deliberately stops producing on the AIO branch.
  The contract now reads as 'may be incomplete' and records that providers
  differ in how precisely they can decide it.
- The changelog no longer lumps 'parse_remote_search_output' in with the
  filtered-match cap: its raw-output cap is a separate limit with its own
  one-line-past accounting, and the other providers' filtered-match cap is
  unchanged.

Also corrects the docstring on the existing test, which still described the
removed early return in the present tense.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Huang-404-Q 2026-09-16 16:00:23 +08:00 committed by GitHub
parent 0745fb268f
commit 6bab87aca4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 142 additions and 4 deletions

View File

@ -582,6 +582,16 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
- **sandbox:** Stop AIO's `glob` from reporting an exactly-full result as
truncated. Its `include_dirs` branch returned as soon as it had collected
`max_results` matches, so a listing that held exactly that many — and no more
— came back flagged as cut off, and the tool told the model the result was
incomplete. That branch already holds the whole listing, so it now looks one
match past the cap before deciding, matching the sibling `include_dirs=False`
branch, which has always decided from the full list. This concerns the
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.
- **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

View File

@ -397,6 +397,12 @@
### 修复
- **沙箱:** AIO 的 `glob` 不再把"恰好填满"的结果报告为截断。其 `include_dirs` 分支在收集到
`max_results` 个匹配时就立即返回,因此一个只有这么多匹配、后面再无匹配的目录列表也会被标记为
被截断,工具据此告诉模型结果不完整。该分支本就持有整份目录列表,现在改为多看一个匹配再判断,
与同一函数的 `include_dirs=False` 分支一致(后者一直是按完整列表判断的)。这里涉及的只是
**过滤后匹配数**上限;`parse_remote_search_output` 管的是**原始输出行数**上限,是另一条限制、
有自己的"多放一行"记账方式,其他 provider 的过滤后匹配数上限未作改动。
- **中间件:** 移除工具调用的守卫不再导致 Claude 或 OpenAI Responses 线程之后的每一轮都失败。
token 预算与循环检测的硬停止、subagent 数量限制的截断以及安全终止抑制只清空了 `tool_calls`
却把 provider 自身的工具调用块留在消息 content 中。Anthropic 与 Responses API 会重新发送这些块,

View File

@ -630,8 +630,15 @@ class AioSandbox(Sandbox):
rel_path = entry.path[len(root_path) :].lstrip("/")
if path_matches(pattern, rel_path):
matches.append(entry.path)
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 listing that held exactly
# ``max_results`` from one that held more, so an exhausted
# listing was reported as truncated; it also returned a match
# for ``max_results=0``. The ``include_dirs=False`` branch
# below and the shared ``parse_remote_search_output`` path
# decide the same way.
if len(matches) > max_results:
return matches[:max_results], True
return matches, False
def grep(

View File

@ -203,8 +203,14 @@ class Sandbox(ABC):
"""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.
matches may be incomplete: the search stopped at an output cap before
filtering, or an eligible match beyond ``max_results`` was dropped.
Providers differ in how precisely they can decide the second case. One
that holds the whole listing can tell an exactly-full result from a
cut-off one and reports the former as complete; one reading a capped
stream cannot, and reports it as truncated. Treat the flag as "may be
incomplete", never as a count.
"""
pass

View File

@ -284,6 +284,115 @@ def test_aio_sandbox_glob_include_dirs_filters_nested_ignored(monkeypatch) -> No
assert truncated is False
def test_aio_sandbox_glob_include_dirs_exactly_full_is_not_truncated(monkeypatch) -> None:
"""A listing whose matches exactly fill max_results is complete.
The branch used to return as soon as it had collected ``max_results``
matches, without looking at the remaining entries, so a listing that held
exactly that many was reported as cut off even though every entry was
seen. It now scans one eligible match past the cap, the same rule the
sibling ``include_dirs=False`` branch has always applied to the full list.
"""
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
monkeypatch.setattr(
sandbox._client.file,
"list_path",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
files=[
SimpleNamespace(name="a", path="/mnt/workspace/a"),
SimpleNamespace(name="b", path="/mnt/workspace/b"),
]
)
),
)
matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True, max_results=2)
assert matches == ["/mnt/workspace/a", "/mnt/workspace/b"]
assert truncated is False
def test_aio_sandbox_glob_include_dirs_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,
"list_path",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
files=[
SimpleNamespace(name="a", path="/mnt/workspace/a"),
SimpleNamespace(name="b", path="/mnt/workspace/b"),
SimpleNamespace(name="c", path="/mnt/workspace/c"),
]
)
),
)
matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True, max_results=2)
assert matches == ["/mnt/workspace/a", "/mnt/workspace/b"]
assert truncated is True
def _filtered_tail_entries(*, second_eligible: bool):
"""Two eligible matches, then entries only the ignore rules, the pattern or
the root scope reject a tail that must not count toward the cap."""
entries = [
SimpleNamespace(name="a.py", path="/mnt/workspace/a.py"),
SimpleNamespace(name="b.py", path="/mnt/workspace/b.py"),
# ignored directory
SimpleNamespace(name="lib.py", path="/mnt/workspace/node_modules/lib.py"),
# outside the search root
SimpleNamespace(name="c.py", path="/mnt/elsewhere/c.py"),
# inside the root but not matched by the pattern
SimpleNamespace(name="notes.txt", path="/mnt/workspace/notes.txt"),
]
if second_eligible:
entries.append(SimpleNamespace(name="c.py", path="/mnt/workspace/c.py"))
return entries
def _patched_sandbox(monkeypatch, entries):
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
monkeypatch.setattr(
sandbox._client.file,
"list_path",
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(files=entries)),
)
return sandbox
def test_aio_sandbox_glob_include_dirs_looks_past_a_filtered_tail(monkeypatch) -> None:
"""Trailing entries the filters reject do not make a full result truncated.
The cap counts *eligible* matches, so the branch has to keep scanning
rather than assume the max-th match was the last one: here two matches fill
``max_results`` and everything after them is ignored, out of root, or a
pattern miss, which leaves the result complete.
"""
sandbox = _patched_sandbox(monkeypatch, _filtered_tail_entries(second_eligible=False))
matches, truncated = sandbox.glob("/mnt/workspace", "**/*.py", include_dirs=True, max_results=2)
assert matches == ["/mnt/workspace/a.py", "/mnt/workspace/b.py"]
assert truncated is False
def test_aio_sandbox_glob_include_dirs_reports_a_match_after_a_filtered_tail(monkeypatch) -> None:
"""An eligible match beyond that filtered tail still reports truncation."""
sandbox = _patched_sandbox(monkeypatch, _filtered_tail_entries(second_eligible=True))
matches, truncated = sandbox.glob("/mnt/workspace", "**/*.py", include_dirs=True, max_results=2)
assert matches == ["/mnt/workspace/a.py", "/mnt/workspace/b.py"]
assert truncated is True
def test_aio_sandbox_grep_invalid_regex_raises() -> None:
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")