From f87a04e581829ff35afd4fa228240071b486192f Mon Sep 17 00:00:00 2001 From: Shxiao Date: Tue, 29 Sep 2026 18:24:29 +0900 Subject: [PATCH] fix(sandbox): unwrap Overwrite-wrapped sandbox state in tools and middlewares (#6015) * fix(sandbox): unwrap Overwrite-wrapped sandbox state in tools and middlewares * fix(changelog): restore database entry and add #4380 reference link, simplify task_tool unwrap * fix(subagents): preserve Overwrite wrapper when task_tool forwards sandbox state to executor --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 8 +++++ CHANGELOG_zh.md | 6 ++++ .../read_before_write_middleware.py | 4 ++- .../tool_output_budget_middleware.py | 4 ++- .../test_read_before_write_middleware.py | 15 +++++++++ backend/tests/test_task_tool_core_logic.py | 33 +++++++++++++++++++ .../test_tool_output_budget_middleware.py | 15 +++++++++ 7 files changed, 83 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2747885c8d..fa8a13c36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,13 @@ This release closes that milestone with **181 merged pull requests**. ### Fixed +- **sandbox:** Unwrap `Overwrite`-wrapped sandbox state in + `ToolOutputBudgetMiddleware` and `ReadBeforeWriteMiddleware`. In delta + checkpoint mode, forked or restored threads deliver the `sandbox` channel + wrapped in LangGraph's `Overwrite`. Without unwrapping, + `isinstance(sandbox_state, dict)` returned `False`, causing large tool output + externalization to fail and fall back to inline truncation, and + read-before-write lock scoping to miss the active sandbox ID. ([#6051]) - **doctor:** `make doctor` now checks the config file the Gateway actually loads. It always inspected `/config.yaml` and ignored `DEER_FLOW_CONFIG_PATH` and `DEER_FLOW_PROJECT_ROOT`, so a missing override @@ -6226,4 +6233,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5981]: https://github.com/bytedance/deer-flow/pull/5981 [#5982]: https://github.com/bytedance/deer-flow/pull/5982 [#5987]: https://github.com/bytedance/deer-flow/pull/5987 +[#6015]: https://github.com/bytedance/deer-flow/pull/6015 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 6edbacfb1f..bf60e85c05 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -332,6 +332,11 @@ ### 修复 +- **沙箱:** 在中间件(`ToolOutputBudgetMiddleware` 与 `ReadBeforeWriteMiddleware`) + 中解包 `Overwrite` 包装的沙箱状态。在 delta checkpoint 模式下,分叉或回滚 + 的对话交付的 `sandbox` 通道状态会被 LangGraph 的 `Overwrite` 包装。此前直接判断 + `isinstance(sandbox_state, dict)` 会返回 `False`,导致大工具输出无法外部化到沙箱而退化为内联 + 硬截断,以及写前读锁作用域丢失有效沙箱 ID。([#6015]) - **doctor:** `make doctor` 现在检查 Gateway 实际加载的配置文件。此前它 固定检查 `/config.yaml`,忽略 `DEER_FLOW_CONFIG_PATH` 与 `DEER_FLOW_PROJECT_ROOT`:指向不存在路径、会让 Gateway 无法启动的覆盖值仍会 @@ -5282,3 +5287,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5981]: https://github.com/bytedance/deer-flow/pull/5981 [#5982]: https://github.com/bytedance/deer-flow/pull/5982 [#5987]: https://github.com/bytedance/deer-flow/pull/5987 +[#6015]: https://github.com/bytedance/deer-flow/pull/6015 diff --git a/backend/packages/harness/deerflow/agents/middlewares/read_before_write_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/read_before_write_middleware.py index 322d672dce..ddcd025cde 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/read_before_write_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/read_before_write_middleware.py @@ -279,7 +279,9 @@ class ReadBeforeWriteMiddleware(AgentMiddleware): return thread_id state = request.state if isinstance(state, dict): - sandbox_state = state.get("sandbox") + from deerflow.sandbox.overwrite import unwrap_sandbox + + sandbox_state, _ = unwrap_sandbox(state.get("sandbox")) if isinstance(sandbox_state, dict): sandbox_id = sandbox_state.get("sandbox_id") if isinstance(sandbox_id, str) and sandbox_id: diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py index 85e5303d09..d4e550dbd2 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @@ -342,7 +342,9 @@ def _resolve_sandbox(request: ToolCallRequest) -> Sandbox | None: state = getattr(runtime, "state", None) if not isinstance(state, dict): return None - sandbox_state = state.get("sandbox") + from deerflow.sandbox.overwrite import unwrap_sandbox + + sandbox_state, _ = unwrap_sandbox(state.get("sandbox")) if not isinstance(sandbox_state, dict): return None sandbox_id = sandbox_state.get("sandbox_id") diff --git a/backend/tests/test_read_before_write_middleware.py b/backend/tests/test_read_before_write_middleware.py index 08228dc1ae..a4ebe9d47b 100644 --- a/backend/tests/test_read_before_write_middleware.py +++ b/backend/tests/test_read_before_write_middleware.py @@ -842,3 +842,18 @@ class TestBlockedPayloadElision: rewritten = self._captured(handler).messages[1] assert rewritten.tool_calls[0]["args"]["content"].startswith("[payload elided: 5000 chars") assert rewritten.tool_calls[1] == ai.tool_calls[1] + + +def test_lock_scope_resolves_overwrite_wrapped_sandbox(): + """Fork-restored state wraps sandbox in Overwrite; lock scope must still resolve it.""" + from langgraph.types import Overwrite + + from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware + + req = ToolCallRequest( + tool_call={"name": "write_file", "args": {}, "id": "tc-1"}, + tool=None, + state={"sandbox": Overwrite({"sandbox_id": "sb-fork-lock"})}, + runtime=MagicMock(context={}), + ) + assert ReadBeforeWriteMiddleware._lock_scope(req) == "sb-fork-lock" diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index b9d5ba04ea..8c719cc76b 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -683,6 +683,39 @@ def test_task_tool_forwards_is_internal_true_to_executor(monkeypatch): assert captured["executor_kwargs"]["is_internal"] is True +def test_task_tool_preserves_overwrite_sandbox_state_for_executor(monkeypatch): + """Fork-restored checkpoints wrap sandbox state in Overwrite; task_tool must preserve the wrapper so executor retains borrow semantics.""" + from langgraph.types import Overwrite + + runtime = _make_runtime() + runtime.state["sandbox"] = Overwrite({"sandbox_id": "sb-fork-123"}) + captured = {} + + class DummyExecutor: + def __init__(self, **kwargs): + captured["executor_kwargs"] = kwargs + + def execute_async(self, prompt, task_id=None): + return task_id or "generated-task-id" + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config()) + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), + ) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None) + monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) + monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) + + _run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-1") + passed_sandbox = captured["executor_kwargs"]["sandbox_state"] + assert isinstance(passed_sandbox, Overwrite) + assert passed_sandbox.value == {"sandbox_id": "sb-fork-123"} + + def test_task_tool_forwards_is_internal_false_to_executor(monkeypatch): """is_internal=False must also propagate explicitly (not skipped).""" runtime = _make_runtime() diff --git a/backend/tests/test_tool_output_budget_middleware.py b/backend/tests/test_tool_output_budget_middleware.py index e5617fc806..c763b4fb55 100644 --- a/backend/tests/test_tool_output_budget_middleware.py +++ b/backend/tests/test_tool_output_budget_middleware.py @@ -1516,6 +1516,21 @@ class TestResolveSandbox: req = SimpleNamespace(runtime=SimpleNamespace(state={"sandbox": {"sandbox_id": "sb-1"}})) assert mod._resolve_sandbox(req) is sb + def test_returns_sandbox_from_provider_when_overwrite_wrapped(self, monkeypatch): + from langgraph.types import Overwrite + + from deerflow.agents.middlewares import tool_output_budget_middleware as mod + + sb = _FakeSandbox() + monkeypatch.setattr( + mod, + "get_sandbox_provider", + lambda: _FakeProvider(uses_thread_data_mounts=False, sandbox=sb), + ) + # Fork-restored state delivers sandbox wrapped in Overwrite + req = SimpleNamespace(runtime=SimpleNamespace(state={"sandbox": Overwrite({"sandbox_id": "sb-fork"})})) + assert mod._resolve_sandbox(req) is sb + def test_returns_none_on_provider_exception(self, monkeypatch): from deerflow.agents.middlewares import tool_output_budget_middleware as mod