mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* docs(specs): read-before-write gate design for issue #3857 output layer * docs(plans): read-before-write gate implementation plan (#3857) * refactor(sandbox): extract read_current_file_content helper (#3857) * feat(middlewares): read-before-write version gate for file tools (#3857) * test(middlewares): pin async read-before-write gate paths (#3857) * feat(config): wire ReadBeforeWriteMiddleware into runtime chain, default on (#3857) * docs(sandbox): document read-before-write gate in tool docstrings and AGENTS.md (#3857) * docs(plans): align plan doc with landed config_version (17) and drop machine-specific paths Addresses Copilot review comments on #3912. * fix(middlewares): read-before-write gate — error-string sandboxes fail open; serialize gate+execution per path (#3912 review) - AIO/E2B read_file reports failures (incl. missing files) as 'Error: ...' strings instead of raising; the gate treated that string as existing file content and blocked first-write creation. Error-string reads now count as uninspectable: gate fails open, no mark is stamped. - LangGraph runs one AIMessage's tool calls concurrently, so two same-turn writes could both pass on one stale mark before either mutation landed (and a read mark could hash a version the model never saw). Gate check + tool execution (and read + mark stamping) now share a per-(thread, path) critical section, separate from the tool-internal file_operation_lock.
428 lines
18 KiB
Python
428 lines
18 KiB
Python
import sys
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
import pytest
|
|
from langchain_core.messages import ToolMessage
|
|
from langgraph.errors import GraphInterrupt
|
|
|
|
from deerflow.agents.middlewares.tool_error_handling_middleware import (
|
|
ToolErrorHandlingMiddleware,
|
|
build_lead_runtime_middlewares,
|
|
build_subagent_runtime_middlewares,
|
|
)
|
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
|
from deerflow.config.app_config import AppConfig, CircuitBreakerConfig
|
|
from deerflow.config.guardrails_config import GuardrailsConfig
|
|
from deerflow.config.model_config import ModelConfig
|
|
from deerflow.config.sandbox_config import SandboxConfig
|
|
|
|
|
|
def _request(name: str = "web_search", tool_call_id: str | None = "tc-1"):
|
|
tool_call = {"name": name}
|
|
if tool_call_id is not None:
|
|
tool_call["id"] = tool_call_id
|
|
return SimpleNamespace(tool_call=tool_call)
|
|
|
|
|
|
def _module(name: str, **attrs):
|
|
module = ModuleType(name)
|
|
for key, value in attrs.items():
|
|
setattr(module, key, value)
|
|
return module
|
|
|
|
|
|
def _make_app_config(*, supports_vision: bool = False) -> AppConfig:
|
|
return AppConfig(
|
|
models=[
|
|
ModelConfig(
|
|
name="test-model",
|
|
display_name="test-model",
|
|
description=None,
|
|
use="langchain_openai:ChatOpenAI",
|
|
model="test-model",
|
|
supports_vision=supports_vision,
|
|
)
|
|
],
|
|
sandbox=SandboxConfig(use="test"),
|
|
guardrails=GuardrailsConfig(enabled=False),
|
|
circuit_breaker=CircuitBreakerConfig(failure_threshold=7, recovery_timeout_sec=11),
|
|
)
|
|
|
|
|
|
def _stub_runtime_middleware_imports(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeMiddleware:
|
|
def __init__(self, *args, **kwargs):
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
|
|
class FakeLLMErrorHandlingMiddleware:
|
|
def __init__(self, *, app_config):
|
|
self.app_config = app_config
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.llm_error_handling_middleware",
|
|
_module(
|
|
"deerflow.agents.middlewares.llm_error_handling_middleware",
|
|
LLMErrorHandlingMiddleware=FakeLLMErrorHandlingMiddleware,
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.thread_data_middleware",
|
|
_module("deerflow.agents.middlewares.thread_data_middleware", ThreadDataMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.sandbox.middleware",
|
|
_module("deerflow.sandbox.middleware", SandboxMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.dangling_tool_call_middleware",
|
|
_module("deerflow.agents.middlewares.dangling_tool_call_middleware", DanglingToolCallMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.sandbox_audit_middleware",
|
|
_module("deerflow.agents.middlewares.sandbox_audit_middleware", SandboxAuditMiddleware=FakeMiddleware),
|
|
)
|
|
|
|
|
|
def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware(monkeypatch: pytest.MonkeyPatch):
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeMiddleware:
|
|
def __init__(self, *args, **kwargs):
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
|
|
class FakeLLMErrorHandlingMiddleware:
|
|
def __init__(self, *, app_config):
|
|
captured["app_config"] = app_config
|
|
|
|
app_config = _make_app_config()
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.llm_error_handling_middleware",
|
|
_module(
|
|
"deerflow.agents.middlewares.llm_error_handling_middleware",
|
|
LLMErrorHandlingMiddleware=FakeLLMErrorHandlingMiddleware,
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.thread_data_middleware",
|
|
_module("deerflow.agents.middlewares.thread_data_middleware", ThreadDataMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.sandbox.middleware",
|
|
_module("deerflow.sandbox.middleware", SandboxMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.dangling_tool_call_middleware",
|
|
_module("deerflow.agents.middlewares.dangling_tool_call_middleware", DanglingToolCallMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.sandbox_audit_middleware",
|
|
_module("deerflow.agents.middlewares.sandbox_audit_middleware", SandboxAuditMiddleware=FakeMiddleware),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.input_sanitization_middleware",
|
|
_module("deerflow.agents.middlewares.input_sanitization_middleware", InputSanitizationMiddleware=FakeMiddleware),
|
|
)
|
|
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, lazy_init=False)
|
|
|
|
assert captured["app_config"] is app_config
|
|
# 8 baseline (InputSanitization, ToolOutputBudget, ThreadData, Sandbox,
|
|
# DanglingToolCall, LLMErrorHandling, SandboxAudit, ToolErrorHandling)
|
|
# + 1 ReadBeforeWriteMiddleware + 1 SafetyFinishReasonMiddleware (both
|
|
# enabled by default).
|
|
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
|
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
|
|
|
assert len(middlewares) == 10
|
|
assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub
|
|
assert isinstance(middlewares[1], ToolOutputBudgetMiddleware)
|
|
assert any(isinstance(m, ToolErrorHandlingMiddleware) for m in middlewares)
|
|
assert isinstance(middlewares[-1], SafetyFinishReasonMiddleware)
|
|
|
|
|
|
def test_build_lead_runtime_middlewares_orders_thread_data_before_uploads():
|
|
"""ThreadDataMiddleware must run before UploadsMiddleware so the uploads
|
|
directory is guaranteed to exist when UploadsMiddleware scans it under
|
|
lazy_init=False. This is the narrow functional concern the chain order
|
|
protects; a regression here would silently drop historical files on the
|
|
first run of a thread when the directory has not been pre-created by the
|
|
upload endpoint.
|
|
"""
|
|
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
|
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
|
|
|
app_config = _make_app_config()
|
|
middlewares = build_lead_runtime_middlewares(app_config=app_config)
|
|
|
|
td_indices = [i for i, m in enumerate(middlewares) if isinstance(m, ThreadDataMiddleware)]
|
|
um_indices = [i for i, m in enumerate(middlewares) if isinstance(m, UploadsMiddleware)]
|
|
|
|
assert td_indices and len(td_indices) == 1, f"expected exactly one ThreadDataMiddleware, got {td_indices}"
|
|
assert um_indices and len(um_indices) == 1, f"expected exactly one UploadsMiddleware, got {um_indices}"
|
|
assert td_indices[0] < um_indices[0], f"ThreadDataMiddleware (idx {td_indices[0]}) must come before UploadsMiddleware (idx {um_indices[0]}) so the uploads directory exists when UploadsMiddleware scans it under lazy_init=False."
|
|
|
|
|
|
def test_build_lead_runtime_middlewares_chain_order_matches_agents_md():
|
|
"""Pin the AGENTS.md middleware numbering for the shared runtime base.
|
|
|
|
The existing tests stub most middlewares as a single ``FakeMiddleware``,
|
|
which cannot detect a reorder. This test uses the real classes so an
|
|
index swap between any pair (e.g. Uploads vs ThreadData, Sandbox vs
|
|
DanglingToolCall) is caught. If a future refactor legitimately reorders
|
|
these, update backend/AGENTS.md "Middleware Chain" in the same change.
|
|
"""
|
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
|
from deerflow.agents.middlewares.input_sanitization_middleware import InputSanitizationMiddleware
|
|
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
|
|
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
|
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
|
|
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
|
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
|
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
|
from deerflow.sandbox.middleware import SandboxMiddleware
|
|
|
|
app_config = _make_app_config()
|
|
middlewares = build_lead_runtime_middlewares(app_config=app_config)
|
|
|
|
def idx_of(cls, *, label: str) -> int:
|
|
matches = [i for i, m in enumerate(middlewares) if isinstance(m, cls)]
|
|
assert matches, f"{label} missing from chain"
|
|
assert len(matches) == 1, f"expected exactly one {label}, got indices {matches}"
|
|
return matches[0]
|
|
|
|
# Mirrors AGENTS.md "Shared runtime base" items 1-10 (non-optional spine).
|
|
expected_order: list[tuple[str, type]] = [
|
|
("InputSanitizationMiddleware", InputSanitizationMiddleware),
|
|
("ToolOutputBudgetMiddleware", ToolOutputBudgetMiddleware),
|
|
("ThreadDataMiddleware", ThreadDataMiddleware),
|
|
("UploadsMiddleware", UploadsMiddleware),
|
|
("SandboxMiddleware", SandboxMiddleware),
|
|
("DanglingToolCallMiddleware", DanglingToolCallMiddleware),
|
|
("LLMErrorHandlingMiddleware", LLMErrorHandlingMiddleware),
|
|
("SandboxAuditMiddleware", SandboxAuditMiddleware),
|
|
("ReadBeforeWriteMiddleware", ReadBeforeWriteMiddleware),
|
|
("ToolErrorHandlingMiddleware", ToolErrorHandlingMiddleware),
|
|
]
|
|
actual = [(label, idx_of(cls, label=label)) for label, cls in expected_order]
|
|
|
|
for (name_a, idx_a), (name_b, idx_b) in zip(actual, actual[1:]):
|
|
assert idx_a < idx_b, f"{name_a} (idx {idx_a}) must come before {name_b} (idx {idx_b}); full chain: {actual}"
|
|
|
|
|
|
def test_wrap_tool_call_passthrough_on_success():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request()
|
|
expected = ToolMessage(content="ok", tool_call_id="tc-1", name="web_search")
|
|
|
|
result = middleware.wrap_tool_call(req, lambda _req: expected)
|
|
|
|
assert result is expected
|
|
|
|
|
|
def test_wrap_tool_call_returns_error_tool_message_on_exception():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request(name="web_search", tool_call_id="tc-42")
|
|
|
|
def _boom(_req):
|
|
raise RuntimeError("network down")
|
|
|
|
result = middleware.wrap_tool_call(req, _boom)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.tool_call_id == "tc-42"
|
|
assert result.name == "web_search"
|
|
assert result.status == "error"
|
|
assert "Tool 'web_search' failed" in result.text
|
|
assert "network down" in result.text
|
|
|
|
|
|
def test_wrap_tool_call_uses_fallback_tool_call_id_when_missing():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request(name="mcp_tool", tool_call_id=None)
|
|
|
|
def _boom(_req):
|
|
raise ValueError("bad request")
|
|
|
|
result = middleware.wrap_tool_call(req, _boom)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.tool_call_id == "missing_tool_call_id"
|
|
assert result.name == "mcp_tool"
|
|
assert result.status == "error"
|
|
|
|
|
|
def test_wrap_tool_call_reraises_graph_interrupt():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request(name="ask_clarification", tool_call_id="tc-int")
|
|
|
|
def _interrupt(_req):
|
|
raise GraphInterrupt(())
|
|
|
|
with pytest.raises(GraphInterrupt):
|
|
middleware.wrap_tool_call(req, _interrupt)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_returns_error_tool_message_on_exception():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request(name="mcp_tool", tool_call_id="tc-async")
|
|
|
|
async def _boom(_req):
|
|
raise TimeoutError("request timed out")
|
|
|
|
result = await middleware.awrap_tool_call(req, _boom)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.tool_call_id == "tc-async"
|
|
assert result.name == "mcp_tool"
|
|
assert result.status == "error"
|
|
assert "request timed out" in result.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_reraises_graph_interrupt():
|
|
middleware = ToolErrorHandlingMiddleware()
|
|
req = _request(name="ask_clarification", tool_call_id="tc-int-async")
|
|
|
|
async def _interrupt(_req):
|
|
raise GraphInterrupt(())
|
|
|
|
with pytest.raises(GraphInterrupt):
|
|
await middleware.awrap_tool_call(req, _interrupt)
|
|
|
|
|
|
def test_subagent_runtime_middlewares_include_view_image_for_vision_model(monkeypatch):
|
|
app_config = _make_app_config(supports_vision=True)
|
|
_stub_runtime_middleware_imports(monkeypatch)
|
|
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model")
|
|
|
|
assert any(isinstance(middleware, ViewImageMiddleware) for middleware in middlewares)
|
|
|
|
|
|
def test_subagent_runtime_middlewares_include_view_image_for_default_vision_model(monkeypatch):
|
|
app_config = _make_app_config(supports_vision=True)
|
|
_stub_runtime_middleware_imports(monkeypatch)
|
|
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name=None)
|
|
|
|
assert any(isinstance(middleware, ViewImageMiddleware) for middleware in middlewares)
|
|
|
|
|
|
def test_subagent_runtime_middlewares_skip_view_image_for_text_model(monkeypatch):
|
|
app_config = _make_app_config(supports_vision=False)
|
|
_stub_runtime_middleware_imports(monkeypatch)
|
|
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name="test-model")
|
|
|
|
assert not any(isinstance(middleware, ViewImageMiddleware) for middleware in middlewares)
|
|
|
|
|
|
def test_subagent_runtime_middlewares_attach_deferred_filter_when_setup_has_names(monkeypatch):
|
|
"""A subagent built with deferred MCP tools gets DeferredToolFilterMiddleware, positioned before SafetyFinishReasonMiddleware (mirrors the lead ordering)."""
|
|
from langchain_core.tools import tool as as_tool
|
|
|
|
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
|
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
|
from deerflow.tools.builtins.tool_search import build_deferred_tool_setup
|
|
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
|
|
|
app_config = _make_app_config()
|
|
_stub_runtime_middleware_imports(monkeypatch)
|
|
|
|
@as_tool
|
|
def mcp_thing(x: str) -> str:
|
|
"deferred mcp tool"
|
|
return x
|
|
|
|
setup = build_deferred_tool_setup([tag_mcp_tool(mcp_thing)], enabled=True)
|
|
assert setup.deferred_names # sanity: populated setup
|
|
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, deferred_setup=setup)
|
|
|
|
filters = [m for m in middlewares if isinstance(m, DeferredToolFilterMiddleware)]
|
|
assert len(filters) == 1
|
|
filter_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, DeferredToolFilterMiddleware))
|
|
safety_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SafetyFinishReasonMiddleware))
|
|
assert filter_idx < safety_idx
|
|
|
|
|
|
def test_subagent_runtime_middlewares_skip_deferred_filter_without_names(monkeypatch):
|
|
"""No deferred setup (disabled / no MCP tool) -> no DeferredToolFilterMiddleware."""
|
|
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
|
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
|
|
|
app_config = _make_app_config()
|
|
_stub_runtime_middleware_imports(monkeypatch)
|
|
|
|
for setup in (None, DeferredToolSetup(None, frozenset(), None)):
|
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, deferred_setup=setup)
|
|
assert not any(isinstance(m, DeferredToolFilterMiddleware) for m in middlewares)
|
|
|
|
|
|
def test_lead_runtime_chain_finds_historical_uploads_under_lazy_init_false(tmp_path, monkeypatch):
|
|
"""Integration anchor for the ThreadData → Uploads ordering.
|
|
|
|
Under lazy_init=False, ThreadDataMiddleware eagerly creates the thread
|
|
directories in before_agent. UploadsMiddleware then scans the uploads
|
|
directory. Running both middlewares via the real build_lead_runtime_middlewares
|
|
chain (TD before UM) must surface pre-existing historical files in the
|
|
injected <uploaded_files> context.
|
|
|
|
This complements the static order contract
|
|
(test_build_lead_runtime_middlewares_orders_thread_data_before_uploads):
|
|
that test pins the chain position; this test pins the observable behavior
|
|
at that position.
|
|
"""
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
|
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
thread_id = "thread-historical-files"
|
|
user_id = get_effective_user_id()
|
|
|
|
paths = Paths(str(tmp_path))
|
|
uploads_dir = paths.sandbox_uploads_dir(thread_id, user_id=user_id)
|
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
(uploads_dir / "prior-report.txt").write_bytes(b"historical payload")
|
|
|
|
td = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=False)
|
|
um = UploadsMiddleware(base_dir=str(tmp_path))
|
|
|
|
runtime = Runtime(context={"thread_id": thread_id, "run_id": "run-1"})
|
|
state = {"messages": [HumanMessage(content="please summarise the prior upload")]}
|
|
|
|
td_result = td.before_agent(state, runtime)
|
|
assert td_result is not None, "ThreadDataMiddleware must run and produce state updates"
|
|
# Sanity: under lazy_init=False the directories were created (not just computed).
|
|
assert uploads_dir.exists(), "ThreadDataMiddleware should have ensured the uploads directory exists"
|
|
|
|
# ThreadDataMiddleware rewrites the last HumanMessage (annotating run_id/timestamp);
|
|
# carry its updated messages into the UploadsMiddleware input state, mirroring
|
|
# how LangGraph chains before_agent outputs into the next middleware.
|
|
um_input = {**state, "messages": td_result["messages"]}
|
|
um_result = um.before_agent(um_input, runtime)
|
|
|
|
assert um_result is not None, "UploadsMiddleware must inject context when historical files exist"
|
|
injected_content = um_result["messages"][-1].content
|
|
assert "<uploaded_files>" in injected_content
|
|
assert "prior-report.txt" in injected_content
|
|
assert "previous messages" in injected_content # historical section header
|