mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 09:55:59 +00:00
fix(sandbox): persist lazily-acquired sandbox state via Command (#3464)
* fix(sandbox): persist lazily-acquired sandbox state via Command
ensure_sandbox_initialized mutates runtime.state in place, which is local
to the current tool invocation and is not picked up by LangGraph's channel
reducer. Subsequent graph steps and downstream consumers (such as
ToolOutputBudgetMiddleware and the sub-agent task_tool) therefore cannot
observe the sandbox id from state.
Wrap tool calls in SandboxMiddleware (wrap_tool_call / awrap_tool_call) to
detect fresh lazy initialization by diffing runtime.state before and after
the handler, and emit a proper state update via Command(update=...):
- ToolMessage results are wrapped into Command(update={sandbox, messages})
- Command results with a dict update are merged on the sandbox key while
preserving messages / goto / graph / resume
- Command results with non-dict updates are left untouched to avoid silent
data loss on unknown update shapes
Tests:
- 7 new unit tests cover lazy-init emit, passthrough, dict-update merge,
non-dict-update passthrough (sync and async)
- Refresh replay golden write_read_file.ultra.events.json: SSE 'values'
events now correctly carry the 'sandbox' key in their keys list, which
is the direct evidence that the fix is effective
Closes #3463
* refactor(sandbox): use dataclasses.replace to preserve Command fields
Address Copilot review on #3464: replace manual field-copy with
dataclasses.replace so any current or future Command fields are
preserved automatically when merging sandbox_update.
Also add a regression test that constructs a Command with non-None
graph/goto/resume to lock this behavior in.
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import replace as dc_replace
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.agents.thread_state import SandboxState, ThreadDataState
|
||||
from deerflow.sandbox import get_sandbox_provider
|
||||
@@ -126,3 +131,87 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
|
||||
|
||||
# No sandbox to release
|
||||
return await super().aafter_agent(state, runtime)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-call wrappers: persist lazily-acquired sandbox state into the
|
||||
# graph state via Command(update=...).
|
||||
#
|
||||
# Background:
|
||||
# ``ensure_sandbox_initialized*`` in ``deerflow.sandbox.tools`` mutates
|
||||
# ``runtime.state["sandbox"]`` directly. That mutation is local to the
|
||||
# current tool invocation and is NOT picked up by LangGraph's channel
|
||||
# reducer, so subsequent graph steps (and downstream consumers such as
|
||||
# ``ToolOutputBudgetMiddleware`` and the sub-agent ``task_tool``)
|
||||
# cannot observe the sandbox id. Wrapping the tool call lets us detect
|
||||
# a fresh lazy init by diffing the state snapshot before/after the
|
||||
# handler and emit a proper state update via ``Command``.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_sandbox_id_from_state(state: object) -> str | None:
|
||||
if not isinstance(state, dict):
|
||||
return None
|
||||
sandbox_state = state.get("sandbox")
|
||||
if not isinstance(sandbox_state, dict):
|
||||
return None
|
||||
sandbox_id = sandbox_state.get("sandbox_id")
|
||||
return sandbox_id if isinstance(sandbox_id, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _attach_sandbox_update(result: ToolMessage | Command, sandbox_id: str) -> ToolMessage | Command:
|
||||
"""Wrap or merge ``result`` so that ``sandbox.sandbox_id`` is persisted.
|
||||
|
||||
- ``ToolMessage`` -> ``Command(update={"sandbox": ..., "messages": [msg]})``
|
||||
- ``Command`` with dict update -> merge ``sandbox`` key, preserve all
|
||||
existing fields (``messages``, ``goto``, ``graph``, ``resume``, ...).
|
||||
- ``Command`` with non-dict / None update -> leave it untouched to
|
||||
avoid silent data loss on unknown update shapes.
|
||||
"""
|
||||
sandbox_update = {"sandbox": {"sandbox_id": sandbox_id}}
|
||||
|
||||
if isinstance(result, ToolMessage):
|
||||
return Command(update={**sandbox_update, "messages": [result]})
|
||||
|
||||
existing_update = result.update
|
||||
if isinstance(existing_update, dict):
|
||||
merged_update = {**existing_update, **sandbox_update}
|
||||
return dc_replace(result, update=merged_update)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _read_sandbox_id_from_request(request: ToolCallRequest) -> str | None:
|
||||
"""Read sandbox_id from runtime.state (where ensure_sandbox_initialized writes)."""
|
||||
runtime = request.runtime
|
||||
if runtime is None or runtime.state is None:
|
||||
return None
|
||||
return SandboxMiddleware._read_sandbox_id_from_state(runtime.state)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
prev_sandbox_id = self._read_sandbox_id_from_request(request)
|
||||
result = handler(request)
|
||||
if prev_sandbox_id is not None:
|
||||
return result
|
||||
curr_sandbox_id = self._read_sandbox_id_from_request(request)
|
||||
if curr_sandbox_id is None:
|
||||
return result
|
||||
return self._attach_sandbox_update(result, curr_sandbox_id)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
prev_sandbox_id = self._read_sandbox_id_from_request(request)
|
||||
result = await handler(request)
|
||||
if prev_sandbox_id is not None:
|
||||
return result
|
||||
curr_sandbox_id = self._read_sandbox_id_from_request(request)
|
||||
if curr_sandbox_id is None:
|
||||
return result
|
||||
return self._attach_sandbox_update(result, curr_sandbox_id)
|
||||
|
||||
Reference in New Issue
Block a user