mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
127 lines
6.3 KiB
Python
127 lines
6.3 KiB
Python
"""File contents are not a JSON error envelope from the read_file tool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from langchain.agents import create_agent
|
|
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": "content_analysis",
|
|
}
|
|
|
|
|
|
@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"] == "auth"
|
|
assert meta["recoverable_by_model"] is False
|
|
assert meta["recommended_next_action"] == "stop"
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["sync", "async"])
|
|
@pytest.mark.parametrize("progress_enabled", [False, True])
|
|
@pytest.mark.parametrize("first_kind", ["json_error", "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 == "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"}
|
|
result = graph.invoke(state, context=context) if mode == "sync" else await graph.ainvoke(state, context=context)
|
|
first, second = [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
|
|
|
assert second.content == 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 == first_content
|