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 <willem.jiang@gmail.com>
This commit is contained in:
Shxiao 2026-09-29 18:24:29 +09:00 committed by GitHub
parent 1b691689da
commit f87a04e581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 83 additions and 2 deletions

View File

@ -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 `<checkout>/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

View File

@ -332,6 +332,11 @@
### 修复
- **沙箱:** 在中间件(`ToolOutputBudgetMiddleware` 与 `ReadBeforeWriteMiddleware`)
中解包 `Overwrite` 包装的沙箱状态。在 delta checkpoint 模式下,分叉或回滚
的对话交付的 `sandbox` 通道状态会被 LangGraph 的 `Overwrite` 包装。此前直接判断
`isinstance(sandbox_state, dict)` 会返回 `False`,导致大工具输出无法外部化到沙箱而退化为内联
硬截断,以及写前读锁作用域丢失有效沙箱 ID。([#6015])
- **doctor:** `make doctor` 现在检查 Gateway 实际加载的配置文件。此前它
固定检查 `<checkout>/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

View File

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

View File

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

View File

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

View File

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

View File

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