deer-flow/backend/tests/test_tool_result_meta_json_depth.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

121 lines
5.6 KiB
Python

"""JSON depth limits must not replace successful native tool results."""
import json
from unittest.mock import AsyncMock, Mock
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 import tool_result_meta as meta
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
from deerflow.agents.thread_state import ThreadState
@pytest.mark.parametrize("status", ["success", "error"])
def test_json_depth_limit_preserves_result(monkeypatch, status):
content = '{"records": [[[0]]], "user_id": 401}'
loads = Mock(side_effect=RecursionError("parser depth limit"))
monkeypatch.setattr(meta.json, "loads", loads)
message = ToolMessage(content=content, name="custom_tool", tool_call_id="depth", status=status)
result = meta.normalize_tool_message(message)
assert result is message
assert result.content == content
assert result.tool_call_id == "depth"
assert result.status == status
assert result.additional_kwargs[meta.TOOL_META_KEY] == {
"status": status,
"error_type": "unknown" if status == "error" else None,
"recoverable_by_model": True,
"recommended_next_action": "try_alternative" if status == "error" else "continue",
"source": "tool_return",
}
assert loads.call_count <= 1
def test_second_json_probe_depth_limit_is_unknown(monkeypatch):
loads = Mock(side_effect=[{}, RecursionError("parser depth limit")])
monkeypatch.setattr(meta.json, "loads", loads)
message = ToolMessage(content='{"user_id": 401}', tool_call_id="depth", status="error")
result = meta.normalize_tool_message(message)
assert loads.call_count == 0
assert result.status == "error"
assert result.additional_kwargs[meta.TOOL_META_KEY]["error_type"] == "unknown"
assert result.additional_kwargs[meta.TOOL_META_KEY]["recommended_next_action"] == "try_alternative"
def test_producer_metadata_bypasses_depth_analysis(monkeypatch):
loads = Mock(side_effect=RecursionError("parser depth limit"))
monkeypatch.setattr(meta.json, "loads", loads)
stamp = {"status": "error", "error_type": "auth", "source": "tool_return"}
message = ToolMessage(content="[0]", tool_call_id="depth", additional_kwargs={meta.TOOL_META_KEY: stamp})
assert meta.normalize_tool_message(message).additional_kwargs[meta.TOOL_META_KEY] is stamp
loads.assert_not_called()
@pytest.mark.parametrize("name,expected", [("custom_tool", "error"), ("read_file", "success")])
def test_shallow_error_envelope_classification(name, expected):
message = ToolMessage(content='{"error": {"code": 401}}', name=name, tool_call_id="shallow")
result = meta.normalize_tool_message(message)
assert result.additional_kwargs[meta.TOOL_META_KEY]["status"] == expected
assert result.additional_kwargs[meta.TOOL_META_KEY]["error_type"] == ("unknown" if expected == "error" else None)
class _ScriptedModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
@pytest.mark.parametrize("mode", ["sync", "async"])
@pytest.mark.parametrize("depth", [10, 10000])
@pytest.mark.asyncio
async def test_native_read_file_graph_preserves_deep_json(tmp_path, monkeypatch, mode, depth):
from deerflow.sandbox import tools as sandbox_tools
from deerflow.sandbox.local.local_sandbox import LocalSandbox
# String construction avoids recursive Python fixtures. Count the deep case
# as regression coverage only when the real parser rejects this fixture.
content = "[" * depth + "0" + "]" * depth
if depth > 10:
try:
json.loads(content)
except RecursionError:
pass
else:
pytest.skip(f"json.loads accepts {depth} nested arrays; parser depth limit is not exercised")
path = tmp_path / "nested.json"
path.write_text(content, encoding="utf-8")
sandbox = LocalSandbox("json-depth-test")
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": str(path)}, "id": "read-depth"}]), AIMessage(content="Done.")]
graph = create_agent(
_ScriptedModel(messages=iter(responses)),
tools=[sandbox_tools.read_file_tool],
middleware=[ToolReceiptMiddleware(render_mode="delegation_only"), ToolErrorHandlingMiddleware()],
state_schema=ThreadState,
)
state = {
"messages": [HumanMessage(content="Read the JSON file.")],
"sandbox": {"sandbox_id": "json-depth-test"},
"thread_data": {"outputs_path": str(tmp_path)},
"thread_directories_created": True,
}
context = {"thread_id": "json-depth-test", "user_id": "default"}
result = graph.invoke(state, context=context) if mode == "sync" else await graph.ainvoke(state, context=context)
message = next(message for message in result["messages"] if isinstance(message, ToolMessage))
assert message.content == content
assert message.tool_call_id == "read-depth"
assert message.status == "success"
assert message.additional_kwargs[meta.TOOL_META_KEY]["status"] == "success"
assert message.additional_kwargs[TOOL_RECEIPT_KEY]["status"] == "success"
assert result["messages"][-1].content == "Done."