deer-flow/backend/tests/test_read_before_write_middleware.py
He Wang fd41fdb065
feat(middleware): add structured tool result meta and tool-progress state machine (#3601)
* feat(middleware): add structured tool result meta and tool-progress state machine

feat:
- Add tool_result_meta.py: ToolResultMeta dataclass (status/error_type/retryable/
  recoverable_by_model/recommended_next_action/source) + normalize_tool_result and
  stamp_exception_meta utilities; classifies every ToolMessage regardless of path
- Add ToolProgressMiddleware: per-(thread_id, tool_name) state machine ACTIVE →
  WARNED (hint injected as HumanMessage) → BLOCKED (call short-circuited); Jaccard
  near-duplicate detection for repeated successful results; auth/config/internal
  errors bypass WARNED and go directly to BLOCKED; LRU-bounded thread state store
- Add ToolProgressConfig: all thresholds configurable (stagnation_threshold,
  warn_escalation_count, jaccard_similarity_threshold, exempt_tools, etc.);
  disabled by default (enabled: false)
- Wire ToolProgressMiddleware as outer wrapper around ToolErrorHandlingMiddleware
  in _build_runtime_middlewares so it receives results already carrying
  deerflow_tool_meta

fix:
- ToolErrorHandlingMiddleware now calls stamp_exception_meta on exception path and
  normalize_tool_result on success path so every ToolMessage carries deerflow_tool_meta

test:
- Add test_tool_result_meta.py: 26 cases covering all classification paths,
  stamp_exception_meta, and normalize_tool_result Command passthrough
- Add test_tool_progress_middleware.py: 27 cases including full async paths,
  Jaccard duplicate detection, LRU eviction, hint injection, and malformed meta
  passthrough
- Extend test_tool_error_handling_middleware.py: middleware ordering invariant and
  meta stamping on exception

docs:
- Add tool_progress section to config.example.yaml with all fields and descriptions
- Update CLAUDE.md middleware chain documentation (entries 8-9)

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>

* fix(middleware): recoverable errors stay WARNED; fix auth keyword shadowing

fix:
- WARNED is terminal for recoverable_by_model=True errors (no_results, not_found,
  permission); hint re-injected on each problem call instead of escalating to
  BLOCKED, so the model can retry with different parameters (e.g. fresh query,
  new URL) without being hard-blocked by a prior stagnation count.
  Non-recoverable (rate_limited, transient) still escalate WARNED → BLOCKED
  after warn_escalation_count more problems; auth/config/internal remain
  immediately BLOCKED.
- Remove bare "api key" keyword from auth classification rule so "no api key
  configured" correctly classifies as config (not auth), producing the accurate
  block-reason text for the model.

docs:
- CLAUDE.md: document all three ToolProgressMiddleware transition paths
- config.example.yaml: update inline state-machine comment to match new paths

test:
- test_recoverable_errors_stay_warned_indefinitely: WARNED never escalates for
  recoverable errors regardless of how many problem calls accumulate
- test_recoverable_error_re_injects_hint_past_escalation: hints continue past
  the escalation zone for recoverable errors
- test_no_api_key_is_config_not_auth: regression guard for keyword shadowing fix

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>

* fix(tool_result_meta): add JSON error extraction and fix source classification

fix:
- Fix non-standard error path: source was "exception" but should be "tool_return"
- Add _extract_json_error_text to isolate JSON error fields from noisy JSON bodies
  (e.g. Brave Search {"error": "...", "query": "..."} — query keywords no longer
  pollute error classification)
- Add success-path JSON extraction to catch tools that return HTTP 200 with a JSON
  error body (status="success" but {"error": "API key not configured"})
- Add _SEMANTIC_ZERO_ERROR_STRINGS frozenset to suppress false positives from tools
  that use {"error": "none"} / {"error": "null"} / {"error": "ok"} as success signals
- Document that stamp_exception_meta always overwrites existing TOOL_META_KEY
  (exception-derived classification is authoritative over tool return-time stamps)

test:
- Add parametrized regression tests for all semantic-zero error strings
- Add tests for non-standard error path source field
- Add tests for JSON error extraction (nonstd, success-path, numeric, falsy values)
- Correct test comment for test_no_api_key_is_config_not_auth

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>

* fix(tool_progress_middleware): fix 6 bugs, add terminal guard and structured logging

fix:
- H1: fix exempt_tools empty-set silently ignored — use `is not None` instead of
  truthiness check so ToolProgressConfig(exempt_tools=set()) correctly disables all
  exemptions
- Fix _get_block_reason creating phantom LRU entries via _get_state (write path);
  now uses dict.get + explicit move_to_end on read path only
- Fix _pending memory leak: LRU eviction of _phase_states now synchronously removes
  all (evicted_thread, *) keys from _pending
- Fix _assess_and_transition missing terminal guard for blocked state — a recoverable
  error result could silently demote blocked → warned in concurrent-race scenarios;
  early return preserves terminal semantics
- Fix recent_word_sets window: stored [-5:] but is_near_duplicate only compared [-3:];
  align to [-3:] and change type list→tuple (prevents accidental in-place mutation
  across dataclasses.replace shallow copies)
- Fix _format_hint missing "success" key and "continue" action: Jaccard near-duplicate
  results produced the generic fallback instead of a specific actionable message

feat:
- Add structured state-transition logging (ACTIVE/WARNED/BLOCKED transitions, blocked
  intercepts, hint injection debug log)

test:
- Add regression tests for all 6 bug fixes (H1, phantom LRU, pending leak, terminal
  guard, window alignment, format_hint near-dup)
- Add Jaccard near-threshold boundary test (7/9 vs 8/9 Jaccard)
- Add production min_words=10 skip test for short content
- Add exempt_tools empty-set and None round-trip tests
- Add _augment_request deduplication test
- Add before_agent current-run preservation test
- Add structured logging tests (WARNED/BLOCKED/ACTIVE/intercepted/debug)

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>

* chore(config): remove unused backward-compat fields from ToolProgressConfig

Remove max_calls_per_intent and window_size fields that were marked
"Retained for backward compatibility; not used by the current state machine"
when the state machine was introduced.  Pydantic v2 ignores unknown fields
by default, so existing config.yaml files with these keys remain valid.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>

* fix(tool_progress): address PR review and multi-agent review findings

fix:
- Remove <80-char length gate for partial_success; only _PARTIAL_MARKERS now
- Add word-boundary regex for numeric HTTP codes (401/403/404/500) to avoid
  false positives like "500ms" or "4010 rows" triggering hard-block
- Add "task" to default exempt_tools (delegation primitive, not a search tool)
- Remove move_to_end() from _get_block_reason read path; blocked threads were
  permanently warm in LRU, starving active threads of eviction slots
- Add _reset_blocked_states in before_agent: scope BLOCKED and WARNED states
  to a single run; clear recent_word_sets so stale Jaccard windows don't cause
  false near-duplicate detections in the next run
- Compute word_set() lazily (only for success results); cap content at 8192
  chars to bound memory and CPU cost on large tool results
- Remove unused retryable field from ToolResultMeta (no consumer existed)
- Add isinstance-based ordering guard and warning log for missing meta
- Fix JSON-without-error-key fallback: use _UNKNOWN_ERROR instead of
  classifying incidental field values (e.g. {"user_id": 401} → auth → stop)
- Fix _extract_json_error_text: use json.dumps for dict/list error fields
  instead of str() which produced Python repr matching config rules spuriously
- Add "no results found"/"no content found"/"no images found" to _PARTIAL_MARKERS
  so success responses with empty results trigger stagnation detection
- Fix immediate-block path to increment consecutive_problems (was left at 0)
- Fix _queue_assessment: skip phantom _pending entries for evicted threads
- Bump config_version 13→16 (upstream added 14/15; tool_progress is additive)

test:
- Update test_short_content_is_partial → test_short_terse_success_is_not_partial
- Add parametrized test_numeric_keyword_word_boundary (8 positive + negative cases)
- Add test_before_agent_resets_blocked_states_for_new_run (strengthened assertions)
- Add test_before_agent_resets_warned_states_for_new_run
- Add test_missing_meta_on_non_exempt_tool_emits_warning
- Add test_middleware_ordering_guard_raises_when_progress_is_inner
- Add test_auth_error_immediately_blocked asserts consecutive_problems == 1
- Add tests for JSON-without-error-key, dict error field, no-results partial_success

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tool_progress): address second PR review — perf, architecture doc, concurrency note

fix:
- Extract content.lower() once before _PARTIAL_MARKERS check in normalize_tool_message;
  previously computed up to 7× per call inside the generator (once per marker)

docs:
- Add division-of-labor paragraph to ToolProgressMiddleware module docstring explaining
  coexistence with LoopDetectionMiddleware: result-quality guard (per-tool BLOCK) vs
  call-pattern guard (whole-turn hard-stop); no shared state, no double-stop risk
- Add threading.Lock comment explaining why asyncio.Lock is not used (short critical
  sections, must also protect sync wrap_tool_call path from subagent executor threads)
- Update backend/CLAUDE.md entry 8 with division-of-labor summary; fix entry 9
  (remove stale retryable field reference, add missing recoverable_by_model/source)

test:
- Add test_tool_progress_and_loop_detection_coexist_without_interfering: drives both
  middlewares to WARNED state simultaneously, verifies independent state, independent
  hint queues, and no cross-contamination; uses snapshot copy for final assertion

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tool_progress): reset all tool states at run boundary; fix semantic-zero test validity

fix:
- _reset_run_states (formerly _reset_blocked_states) drops the phase filter and
  resets all tracked (thread, tool) pairs unconditionally at before_agent; ACTIVE
  tools with sub-threshold consecutive_problems or cached recent_word_sets no longer
  bleed into the next run, preventing spurious WARNED transitions on clean R2 calls
- test_normalize_json_semantic_zero_error_string_not_treated_as_error: replace
  {error_value!r} f-string (produces invalid JSON with single quotes) with
  json.dumps so _extract_json_error_text actually parses the payload and the
  _SEMANTIC_ZERO_ERROR_STRINGS guard is exercised, not bypassed at json.loads

test:
- add test_before_agent_resets_active_state_consecutive_problems_and_word_sets to
  lock the ACTIVE-phase run-boundary reset: drives tool to active/cp=1/ws≠() in R1,
  asserts both fields are zero/empty after before_agent fires for R2

* docs(tool_progress): document intentional per-run reset vs LoopDetection thread-scoped retention

Addresses reviewer observation in PR #3601 that _reset_run_states diverges
from LoopDetectionMiddleware's cross-run scoping policy without explanation.

Expands the _reset_run_states docstring to record the intentional design
choice: ToolProgressMiddleware resets per-run because result-quality errors
(rate_limited, transient) are time-bound and may resolve between turns —
retaining stale counters would risk false-positive BLOCKED calls.
LoopDetectionMiddleware retains history across runs because call-pattern
loops are time-invariant. The divergence is by design, not oversight.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(middleware): restore ReadBeforeWriteMiddleware as outermost write gate

A merge conflict resolution had accidentally placed ReadBeforeWriteMiddleware
after ToolErrorHandlingMiddleware (inner), reversing the original intent from
b81334cc where it was the outermost write gate before ToolErrorHandling.

fix:
- Restore ReadBeforeWriteMiddleware to outer position: ReadBeforeWrite →
  ToolProgress → ToolErrorHandling. Blocked writes now return immediately
  without consuming a ToolProgress slot.
- Add normalize_tool_result call on blocked ToolMessages so they carry
  deerflow_tool_meta (recoverable_by_model=True) even though they bypass
  ToolErrorHandlingMiddleware.

test:
- Add test_blocked_write_has_deerflow_tool_meta (sync + async) to lock the
  normalize_tool_result behavior on blocked writes.
- Fix chain order assertions in TestChainWiring and
  test_build_lead_runtime_middlewares_chain_order_matches_agents_md.

docs:
- Renumber AGENTS.md items: 10→ReadBeforeWrite, 11→ToolProgress,
  12→ToolErrorHandling; update descriptions to reflect outermost-gate design.
- Fix stale cross-reference: LoopDetectionMiddleware (item 23) → (item 25).

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-06 20:57:41 +08:00

439 lines
20 KiB
Python

"""Tests for the read-before-write gate (issue #3857, output layer)."""
import hashlib
import posixpath
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _make_request(name, args, messages=()):
runtime = MagicMock()
runtime.context = {"thread_id": "t-test"}
return ToolCallRequest(
tool_call={"name": name, "args": args, "id": "call-1"},
tool=None,
state={"messages": list(messages)},
runtime=runtime,
)
def _read_marked_message(path, content, tool_call_id="r1"):
msg = ToolMessage(content=content[:20], tool_call_id=tool_call_id, name="read_file")
msg.additional_kwargs["deerflow_read_mark"] = {"path": path, "hash": _sha(content)}
return msg
def _middleware(files: dict[str, str]):
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
def reader(_runtime, path):
normalized = posixpath.normpath(path)
if normalized not in files:
raise FileNotFoundError(path)
value = files[normalized]
if isinstance(value, Exception):
raise value
return value
return ReadBeforeWriteMiddleware(content_reader=reader)
class TestReadCurrentFileContent:
def test_reads_via_sandbox_with_resolution(self):
from deerflow.sandbox import tools as sandbox_tools
sandbox = MagicMock()
sandbox.read_file.return_value = "hello"
runtime = MagicMock()
with (
patch.object(sandbox_tools, "ensure_sandbox_initialized", return_value=sandbox),
patch.object(sandbox_tools, "ensure_thread_directories_exist"),
patch.object(sandbox_tools, "is_local_sandbox", return_value=False),
):
assert sandbox_tools.read_current_file_content(runtime, "/mnt/user-data/outputs/report.md") == "hello"
sandbox.read_file.assert_called_once_with("/mnt/user-data/outputs/report.md")
def test_propagates_file_not_found(self):
from deerflow.sandbox import tools as sandbox_tools
sandbox = MagicMock()
sandbox.read_file.side_effect = FileNotFoundError()
with (
patch.object(sandbox_tools, "ensure_sandbox_initialized", return_value=sandbox),
patch.object(sandbox_tools, "ensure_thread_directories_exist"),
patch.object(sandbox_tools, "is_local_sandbox", return_value=False),
):
with pytest.raises(FileNotFoundError):
sandbox_tools.read_current_file_content(MagicMock(), "/mnt/user-data/outputs/missing.md")
class TestReadMarkStamping:
def test_read_file_success_stamps_mark(self):
mw = _middleware({"/mnt/user-data/outputs/report.md": "v1"})
request = _make_request("read_file", {"description": "d", "path": "/mnt/user-data/outputs/report.md"})
handler = MagicMock(return_value=ToolMessage(content="v1", tool_call_id="call-1", name="read_file"))
result = mw.wrap_tool_call(request, handler)
mark = result.additional_kwargs["deerflow_read_mark"]
assert mark == {"path": "/mnt/user-data/outputs/report.md", "hash": _sha("v1")}
def test_ranged_read_stamps_full_file_hash(self):
mw = _middleware({"/mnt/user-data/outputs/report.md": "line1\nline2\nline3"})
request = _make_request(
"read_file",
{"description": "d", "path": "/mnt/user-data/outputs/report.md", "start_line": 3, "end_line": 3},
)
handler = MagicMock(return_value=ToolMessage(content="line3", tool_call_id="call-1", name="read_file"))
result = mw.wrap_tool_call(request, handler)
assert result.additional_kwargs["deerflow_read_mark"]["hash"] == _sha("line1\nline2\nline3")
def test_error_tool_message_gets_no_mark(self):
mw = _middleware({"/mnt/user-data/outputs/report.md": "v1"})
request = _make_request("read_file", {"description": "d", "path": "/mnt/user-data/outputs/report.md"})
handler = MagicMock(return_value=ToolMessage(content="boom", tool_call_id="call-1", name="read_file", status="error"))
result = mw.wrap_tool_call(request, handler)
assert "deerflow_read_mark" not in result.additional_kwargs
def test_reader_failure_means_no_mark(self):
mw = _middleware({"/mnt/user-data/outputs/report.md": RuntimeError("sandbox down")})
request = _make_request("read_file", {"description": "d", "path": "/mnt/user-data/outputs/report.md"})
handler = MagicMock(return_value=ToolMessage(content="v1", tool_call_id="call-1", name="read_file"))
result = mw.wrap_tool_call(request, handler)
assert "deerflow_read_mark" not in result.additional_kwargs
def test_non_file_tools_untouched(self):
mw = _middleware({})
request = _make_request("bash", {"description": "d", "command": "ls"})
sentinel = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
handler = MagicMock(return_value=sentinel)
assert mw.wrap_tool_call(request, handler) is sentinel
class TestWriteGate:
PATH = "/mnt/user-data/outputs/report.md"
def test_new_file_write_allowed(self):
mw = _middleware({}) # file does not exist
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v1"})
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_overwrite_existing_without_read_blocked(self):
mw = _middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert result.tool_call_id == "call-1"
assert "read" in result.content.lower()
def test_append_without_read_blocked(self):
mw = _middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "more", "append": True})
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
def test_str_replace_without_read_blocked(self):
mw = _middleware({self.PATH: "v1"})
request = _make_request("str_replace", {"description": "d", "path": self.PATH, "old_str": "v1", "new_str": "v2"})
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
def test_str_replace_missing_file_passes_through(self):
mw = _middleware({})
request = _make_request("str_replace", {"description": "d", "path": self.PATH, "old_str": "a", "new_str": "b"})
native_error = ToolMessage(content="Error: File not found", tool_call_id="call-1", name="str_replace", status="error")
handler = MagicMock(return_value=native_error)
assert mw.wrap_tool_call(request, handler) is native_error
def test_fresh_mark_allows_write(self):
mw = _middleware({self.PATH: "v1"})
messages = [HumanMessage("hi"), AIMessage(""), _read_marked_message(self.PATH, "v1")]
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"}, messages)
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_stale_mark_after_modification_blocked(self):
mw = _middleware({self.PATH: "v2"}) # file changed since the read of v1
messages = [_read_marked_message(self.PATH, "v1")]
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v3", "append": True}, messages)
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
def test_newest_mark_wins(self):
mw = _middleware({self.PATH: "v2"})
messages = [_read_marked_message(self.PATH, "v1", "r1"), _read_marked_message(self.PATH, "v2", "r2")]
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v3"}, messages)
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_mark_removed_by_summarization_blocks(self):
mw = _middleware({self.PATH: "v1"})
messages = [HumanMessage("Here is a summary of the conversation to date: ...", name="summary")]
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"}, messages)
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
def test_gate_read_failure_fails_open(self):
mw = _middleware({self.PATH: RuntimeError("sandbox hiccup")})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_normalized_path_matching(self):
mw = _middleware({self.PATH: "v1"})
messages = [_read_marked_message(self.PATH, "v1")]
request = _make_request("write_file", {"description": "d", "path": "/mnt/user-data/outputs/../outputs/report.md", "content": "v2"}, messages)
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_blocked_write_has_deerflow_tool_meta(self):
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
mw = _middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
result = mw.wrap_tool_call(request, MagicMock())
meta = (result.additional_kwargs or {}).get(TOOL_META_KEY)
assert meta is not None, "blocked write must carry deerflow_tool_meta"
assert meta["recoverable_by_model"] is True
class TestAsyncPaths:
PATH = "/mnt/user-data/outputs/report.md"
def test_async_block(self):
import asyncio
mw = _middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
async def handler(_request):
raise AssertionError("handler must not run when blocked")
result = asyncio.run(mw.awrap_tool_call(request, handler))
assert result.status == "error"
def test_async_blocked_write_has_deerflow_tool_meta(self):
import asyncio
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
mw = _middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
async def handler(_request):
raise AssertionError("handler must not run when blocked")
result = asyncio.run(mw.awrap_tool_call(request, handler))
meta = (result.additional_kwargs or {}).get(TOOL_META_KEY)
assert meta is not None, "async blocked write must carry deerflow_tool_meta"
assert meta["recoverable_by_model"] is True
def test_async_read_stamps_mark(self):
import asyncio
mw = _middleware({self.PATH: "v1"})
request = _make_request("read_file", {"description": "d", "path": self.PATH})
async def handler(_request):
return ToolMessage(content="v1", tool_call_id="call-1", name="read_file")
result = asyncio.run(mw.awrap_tool_call(request, handler))
assert result.additional_kwargs["deerflow_read_mark"]["hash"] == _sha("v1")
def test_async_allowed_write_calls_handler(self):
import asyncio
mw = _middleware({self.PATH: "v1"})
messages = [_read_marked_message(self.PATH, "v1")]
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"}, messages)
async def handler(_request):
return ToolMessage(content="OK", tool_call_id="call-1", name="write_file")
result = asyncio.run(mw.awrap_tool_call(request, handler))
assert result.status != "error"
def _wiring_app_config(**overrides):
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
return AppConfig(sandbox=SandboxConfig(use="test"), **overrides)
class TestChainWiring:
def test_enabled_by_default_in_runtime_chain(self):
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware, build_lead_runtime_middlewares
middlewares = build_lead_runtime_middlewares(app_config=_wiring_app_config())
types = [type(m) for m in middlewares]
assert ReadBeforeWriteMiddleware in types
assert types.index(SandboxAuditMiddleware) < types.index(ReadBeforeWriteMiddleware) < types.index(ToolErrorHandlingMiddleware)
def test_disabled_removes_middleware(self):
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
from deerflow.config.read_before_write_config import ReadBeforeWriteConfig
app_config = _wiring_app_config(read_before_write=ReadBeforeWriteConfig(enabled=False))
middlewares = build_lead_runtime_middlewares(app_config=app_config)
assert ReadBeforeWriteMiddleware not in [type(m) for m in middlewares]
def test_subagents_get_the_gate_too(self):
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
middlewares = build_subagent_runtime_middlewares(app_config=_wiring_app_config())
assert ReadBeforeWriteMiddleware in [type(m) for m in middlewares]
class TestErrorStringSandboxes:
"""AIO/E2B sandboxes report read failures as "Error: ..." strings, not exceptions."""
PATH = "/mnt/user-data/outputs/report.md"
def _error_string_middleware(self, files):
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
def reader(_runtime, path):
normalized = posixpath.normpath(path)
if normalized not in files:
return f"Error: can't read file {path}: file not found"
return files[normalized]
return ReadBeforeWriteMiddleware(content_reader=reader)
def test_new_file_creation_not_blocked(self):
mw = self._error_string_middleware({})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v1"})
handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(request, handler)
handler.assert_called_once()
assert result.status != "error"
def test_no_mark_when_reread_returns_error_string(self):
mw = self._error_string_middleware({})
request = _make_request("read_file", {"description": "d", "path": self.PATH})
handler = MagicMock(return_value=ToolMessage(content="v1", tool_call_id="call-1", name="read_file"))
result = mw.wrap_tool_call(request, handler)
assert "deerflow_read_mark" not in result.additional_kwargs
def test_existing_file_still_gated(self):
mw = self._error_string_middleware({self.PATH: "v1"})
request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"})
handler = MagicMock()
result = mw.wrap_tool_call(request, handler)
handler.assert_not_called()
assert result.status == "error"
def test_existing_file_read_still_marked_and_write_allowed(self):
mw = self._error_string_middleware({self.PATH: "v1"})
read_request = _make_request("read_file", {"description": "d", "path": self.PATH})
read_handler = MagicMock(return_value=ToolMessage(content="v1", tool_call_id="r1", name="read_file"))
read_result = mw.wrap_tool_call(read_request, read_handler)
assert read_result.additional_kwargs["deerflow_read_mark"]["hash"] == _sha("v1")
write_request = _make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"}, [read_result])
write_handler = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
result = mw.wrap_tool_call(write_request, write_handler)
write_handler.assert_called_once()
assert result.status != "error"
class TestSamePathSerialization:
"""LangGraph runs one AIMessage's tool calls concurrently; the gate must not
let two same-turn writes pass on one stale mark (issue #3912 review)."""
PATH = "/mnt/user-data/outputs/report.md"
def test_parallel_appends_exactly_one_lands(self):
import asyncio
files = {self.PATH: "v1"}
mw = _middleware(files)
messages = [_read_marked_message(self.PATH, "v1")]
def make_handler(suffix):
async def handler(_request):
await asyncio.sleep(0.02)
files[self.PATH] = files[self.PATH] + suffix
return ToolMessage(content="OK", tool_call_id="call-1", name="write_file")
return handler
async def run():
return await asyncio.gather(
mw.awrap_tool_call(
_make_request("write_file", {"description": "d", "path": self.PATH, "content": "A", "append": True}, messages),
make_handler("A"),
),
mw.awrap_tool_call(
_make_request("write_file", {"description": "d", "path": self.PATH, "content": "B", "append": True}, messages),
make_handler("B"),
),
)
results = asyncio.run(run())
assert sorted(r.status for r in results) == ["error", "success"]
assert files[self.PATH] in ("v1A", "v1B")
def test_read_mark_matches_content_shown_to_model(self):
import asyncio
files = {self.PATH: "v1"}
mw = _middleware(files)
write_messages = [_read_marked_message(self.PATH, "v1")]
async def read_handler(_request):
snapshot = files[self.PATH]
await asyncio.sleep(0.03)
return ToolMessage(content=snapshot, tool_call_id="r-call", name="read_file")
async def write_handler(_request):
files[self.PATH] = "v2"
return ToolMessage(content="OK", tool_call_id="w-call", name="write_file")
async def run():
read_task = asyncio.create_task(mw.awrap_tool_call(_make_request("read_file", {"description": "d", "path": self.PATH}), read_handler))
await asyncio.sleep(0.01)
write_task = asyncio.create_task(
mw.awrap_tool_call(
_make_request("write_file", {"description": "d", "path": self.PATH, "content": "v2"}, write_messages),
write_handler,
)
)
return await asyncio.gather(read_task, write_task)
read_result, _write_result = asyncio.run(run())
mark = read_result.additional_kwargs.get("deerflow_read_mark")
assert mark is not None
assert mark["hash"] == _sha(read_result.content)