deer-flow/backend/tests/test_read_file_json_result_meta.py
Eilen Shin aaca3eeaeb
refactor(agent): ground runtime decisions in structured evidence (#7986)
* refactor(agent): ground runtime decisions in structured evidence

Use producer status and structured errors for tool recovery, preserve memory context without lexical confirmation, and remove predetermined receipt and skill conclusions.

Require explicit MCP discovery and authenticated schedule trial actions. Preserve authorization, ownership, isolation, and bounded execution; update documentation and regression coverage.

* test(sandbox): expect read_file initialization error callback

* fix(agent): address evidence review follow-ups

* fix(agent): preserve retry budgets for OpenAI transport errors

* fix(agent): expose tool execution status in model requests

Project validated execution metadata and exact tool text into request-only JSON so status survives provider serialization. Preserve stored results and receipt hashes, cover SDK and graph boundaries, and migrate replay input hashes without changing recorded responses.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 22:00:23 +08:00

175 lines
8.8 KiB
Python

"""File contents are not a JSON error envelope from the read_file tool."""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langchain.agents import create_agent
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
from deerflow.agents.middlewares.tool_progress_middleware import ToolProgressMiddleware
from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY, normalize_tool_message
from deerflow.agents.thread_state import ThreadState
from deerflow.config.tool_progress_config import ToolProgressConfig
@pytest.mark.parametrize("error", ["Unauthorized", {"code": 401, "message": "Unauthorized"}, ["missing config"], 500, True, "no results found"])
def test_read_file_json_error_field_is_document_data(error):
content = json.dumps({"error": error})
message = ToolMessage(content=content, name="read_file", tool_call_id="read")
result = normalize_tool_message(message)
assert result.content == content
assert result.additional_kwargs[TOOL_META_KEY] == {
"status": "success",
"error_type": None,
"recoverable_by_model": True,
"recommended_next_action": "continue",
"source": "tool_return",
}
@pytest.mark.parametrize("name,status", [("read_file", "error"), ("web_search", "success"), ("custom_tool", "success")])
def test_actual_tool_json_errors_are_still_classified(name, status):
message = ToolMessage(content='{"error": {"code": 401, "message": "Unauthorized"}}', name=name, tool_call_id="call", status=status)
meta = normalize_tool_message(message).additional_kwargs[TOOL_META_KEY]
assert meta["status"] == "error"
assert meta["error_type"] == "unknown"
assert meta["recoverable_by_model"] is True
assert meta["recommended_next_action"] == "try_alternative"
@pytest.mark.parametrize("source", ["exception", "tool_return", "custom"])
def test_read_file_preserves_explicit_failure_metadata(source):
metadata = {"status": "error", "source": source, "error_type": "auth"}
message = ToolMessage(content='{"error":"Unauthorized"}', name="read_file", tool_call_id="read", additional_kwargs={TOOL_META_KEY: metadata})
assert normalize_tool_message(message).additional_kwargs[TOOL_META_KEY] is metadata
class _ScriptedModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
class _ModelRequests(BaseCallbackHandler):
def __init__(self):
self.messages = []
def on_chat_model_start(self, serialized, messages, **kwargs):
self.messages.append(messages[0])
@pytest.mark.parametrize("mode", ["sync", "async"])
@pytest.mark.parametrize("progress_enabled", [False, True])
@pytest.mark.parametrize("first_kind", ["json_error", "error_prose", "marker_prose", "success", "missing", "truncated"])
@pytest.mark.asyncio
async def test_native_read_file_keeps_followup_reads_available(tmp_path, monkeypatch, mode, progress_enabled, first_kind):
from deerflow.sandbox import tools as sandbox_tools
from deerflow.sandbox.local.local_sandbox import LocalSandbox
outputs = tmp_path / "outputs"
outputs.mkdir()
first_content = '{"error": {"code": 401, "message": "Unauthorized"}}\n'
if first_kind == "success":
first_content = '{"result": "OK"}\n'
elif first_kind == "error_prose":
first_content = "Error: Permission denied is the example message in this manual.\n"
elif first_kind == "marker_prose":
first_content = "A truncation example: ... [truncated: showing 1 of 10 chars] ...\n"
elif first_kind == "truncated":
first_content = json.dumps({"error": "Unauthorized", "details": "sample line\n" * 10000})
if first_kind != "missing":
(outputs / "response.json").write_text(first_content, encoding="utf-8")
notes = "The saved response belongs to an earlier request.\n"
(outputs / "notes.txt").write_text(notes, encoding="utf-8")
sandbox = LocalSandbox("json-result-test")
reads = []
original_read = sandbox.read_file
def trace_read(path, *args, **kwargs):
reads.append(path)
return original_read(path, *args, **kwargs)
monkeypatch.setattr(sandbox, "read_file", trace_read)
monkeypatch.setattr(sandbox_tools, "ensure_sandbox_initialized", lambda *_args, **_kwargs: sandbox)
monkeypatch.setattr(sandbox_tools, "ensure_sandbox_initialized_async", AsyncMock(return_value=sandbox))
responses = [AIMessage(content="", tool_calls=[{"name": "read_file", "args": {"path": f"/mnt/user-data/outputs/{filename}"}, "id": f"read-{index}"}]) for index, filename in enumerate(("response.json", "notes.txt"))]
responses.append(AIMessage(content="Done."))
middleware = [ToolReceiptMiddleware(render_mode="delegation_only"), ReadBeforeWriteMiddleware()]
if progress_enabled:
middleware.append(ToolProgressMiddleware.from_config(ToolProgressConfig(enabled=True)))
middleware.append(ToolErrorHandlingMiddleware())
graph = create_agent(_ScriptedModel(messages=iter(responses)), tools=[sandbox_tools.read_file_tool], middleware=middleware, state_schema=ThreadState)
state = {
"messages": [HumanMessage(content="Read the saved API response and then the notes explaining it.")],
"sandbox": {"sandbox_id": "local:json-result-test"},
"thread_data": {"outputs_path": str(outputs)},
"thread_directories_created": True,
}
context = {"thread_id": "json-result-test", "user_id": "default"}
captured = _ModelRequests()
config = {"callbacks": [captured]}
result = graph.invoke(state, context=context, config=config) if mode == "sync" else await graph.ainvoke(state, context=context, config=config)
first, second = [message for message in result["messages"] if isinstance(message, ToolMessage)]
visible = [message for message in captured.messages[-1] if isinstance(message, ToolMessage)]
assert len(visible) == 2
for stored, projected in zip((first, second), visible, strict=True):
assert json.loads(projected.content) == {
TOOL_META_KEY: {key: stored.additional_kwargs[TOOL_META_KEY][key] for key in ("status", "source")},
"content": stored.content,
}
assert projected is not stored
assert projected.tool_call_id == stored.tool_call_id
assert projected.additional_kwargs[TOOL_RECEIPT_KEY] == stored.additional_kwargs[TOOL_RECEIPT_KEY]
assert second.content.replace("\r\n", "\n") == notes
assert str(outputs / "notes.txt") in reads
assert second.additional_kwargs[TOOL_RECEIPT_KEY]["status"] == "success"
expected_status = {"missing": "error", "truncated": "partial_success"}.get(first_kind, "success")
assert first.additional_kwargs[TOOL_META_KEY]["status"] == expected_status
assert first.additional_kwargs[TOOL_RECEIPT_KEY]["status"] == expected_status
if first_kind == "missing":
assert first.content.startswith("Error: File not found:")
assert first.additional_kwargs[TOOL_META_KEY]["error_type"] == "not_found"
elif first_kind == "truncated":
assert len(first.content) < len(first_content)
assert "truncated" in first.content.lower()
else:
assert first.content.replace("\r\n", "\n") == first_content
@pytest.mark.asyncio
@pytest.mark.parametrize("exc,category", [(FileNotFoundError("missing provider"), "not_found"), (PermissionError("denied"), "permission"), (TimeoutError("slow"), "transient"), (RuntimeError("403 forbidden"), "unknown")])
async def test_read_file_async_initialization_failure_has_producer_state(monkeypatch, exc, category):
from deerflow.sandbox import tools as sandbox_tools
@asynccontextmanager
async def authorized(_runtime):
yield
monkeypatch.setattr(sandbox_tools, "sandbox_authorization_scope_async", authorized)
monkeypatch.setattr(sandbox_tools, "ensure_sandbox_initialized_async", AsyncMock(side_effect=exc))
runtime = SimpleNamespace(tool_call_id="read-init", context={}, state={})
message = await sandbox_tools._read_file_tool_async(runtime, "/mnt/user-data/outputs/notes.txt")
assert isinstance(message, ToolMessage)
assert message.status == "error"
assert message.additional_kwargs[TOOL_META_KEY]["status"] == "error"
assert message.additional_kwargs[TOOL_META_KEY]["error_type"] == category
assert message.additional_kwargs[TOOL_META_KEY]["source"] == "tool_return"