diff --git a/CHANGELOG.md b/CHANGELOG.md index 085be63c3..da8cf5b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index d3055b3f7..32de22166 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 会重新发送这些块, 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 494f8bdaf..9052b70cc 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -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( diff --git a/backend/packages/harness/deerflow/sandbox/sandbox.py b/backend/packages/harness/deerflow/sandbox/sandbox.py index dca0c5e64..c94ad3d03 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox.py @@ -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 diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index 61f3dc97e..8024b6d2d 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -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")