mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-31 15:40:37 +00:00
* fix(agents): tolerate deeply nested JSON tool results * test(agents): verify the deep JSON parser premise Signed-off-by: Willem Jiang <willem.jiang@gmail.com> --------- Signed-off-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
121 lines
5.7 KiB
Python
121 lines
5.7 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" if status == "error" else "content_analysis",
|
|
}
|
|
assert loads.call_count == (2 if status == "error" else 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 == 2
|
|
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"] == ("auth" 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."
|