deer-flow/backend/tests/test_run_recursion_capped.py
Totoro c48447a4ae
fix(runtime): present outputs before a loop wrap-up and explain step-limit stops (#7588)
* fix(runtime): present outputs before a loop wrap-up and explain step-limit stops

Loop-detection warnings now ask the agent to present the files it already
created in /mnt/user-data/outputs before its final answer, when
present_files is available.

A run that exhausts recursion_limit now ends with stop_reason
recursion_capped and a plain message that lists outputs it created but
did not present. An active goal stands down without an evaluator call.
The /wait error envelope keeps the GraphRecursionError name, and IM
channels reply with the step-limit message instead of a generic failure.

* docs(changelog): note run-end delivery and step-limit stops (#7588)

* test(runtime): stop pinning LangGraph's step count in the step-limit test

The produced paths and the message already follow the observed model
calls, so the exact count only pinned LangGraph's per-step accounting.
Also note on the step-limit goal branch that its caller passes no
evaluator factory, so it has to stay ahead of the evaluator section.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-27 10:34:34 +08:00

165 lines
7.9 KiB
Python

"""A run that exhausts recursion_limit reports a plain stop, its unpresented outputs, and stands its goal down."""
import asyncio
import json
import socket
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
import pytest
from langchain.agents import create_agent
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import ConfigDict
from deerflow.agents.thread_state import ThreadState
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.goal import build_goal_state, read_thread_goal, write_thread_goal
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.user_context import get_effective_user_id, reset_current_user, set_current_user
class _ToolLoopModel(BaseChatModel):
"""Calls the writer on every turn, so the graph only stops at its recursion limit."""
calls: list[int]
model_config = ConfigDict(arbitrary_types_allowed=True)
@property
def _llm_type(self) -> str:
return "scripted-recursion-cap"
def bind_tools(self, tools, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self.calls.append(len(self.calls) + 1)
n = len(self.calls)
message = AIMessage(content=f"Working on part {n}.", tool_calls=[{"id": f"w-{n}", "name": "write_part", "args": {"n": n}}])
return ChatResult(generations=[ChatGeneration(message=message)])
async def _run_until_recursion_cap(tmp_path: Path, *, with_goal: bool) -> dict[str, Any]:
evaluator_calls: list[Any] = []
published: list[tuple[str, Any]] = []
async def publish(run_id, event, data):
published.append((event, data))
bridge = SimpleNamespace(publish=publish, publish_end=lambda run_id: asyncio.sleep(0), cleanup=lambda run_id, delay=0: asyncio.sleep(0))
saver = InMemorySaver()
events = MemoryRunEventStore()
manager = RunManager(store=MemoryRunStore())
paths = Paths(base_dir=tmp_path)
token = set_current_user(SimpleNamespace(id="recursion-cap-user", role="admin"))
try:
with (
patch("deerflow.config.paths._paths", paths),
patch.dict("os.environ", {"DEER_FLOW_HOME": str(tmp_path), "LANGSMITH_TRACING": "false", "LANGCHAIN_TRACING_V2": "false"}),
patch.object(worker, "create_goal_evaluator_model", lambda **kwargs: evaluator_calls.append(kwargs)),
):
record = await manager.create("recursion-cap-thread", user_id="recursion-cap-user")
outputs = paths.sandbox_outputs_dir(record.thread_id, user_id=get_effective_user_id())
outputs.mkdir(parents=True, exist_ok=True)
@tool
def write_part(n: int) -> str:
"""Write one part of the report."""
(outputs / f"part-{n}.md").write_text(f"part {n}\n", encoding="utf-8")
return f"wrote part-{n}.md"
model = _ToolLoopModel(calls=[])
graph = create_agent(model=model, tools=[write_part], state_schema=ThreadState, context_schema=dict, checkpointer=saver)
app_config = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
app_config.title.enabled = app_config.memory.enabled = app_config.memory.injection_enabled = False
if with_goal:
await write_thread_goal(saver, record.thread_id, build_goal_state("Write the report.", max_continuations=2), create_if_missing=True)
await worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(checkpointer=saver, event_store=events, app_config=app_config),
agent_factory=lambda config: graph,
graph_input={"messages": [HumanMessage(content="Write the report in parts.")]},
config={"configurable": {"thread_id": record.thread_id}, "recursion_limit": 6},
stream_modes=["values"],
)
goal = await read_thread_goal(saver, record.thread_id)
recorded = await events.list_events(record.thread_id, record.run_id)
finally:
reset_current_user(token)
return {
"record": record,
"goal": goal,
"error_frames": [data for event, data in published if event == "error"],
"deliveries": [event["content"] for event in recorded if event["event_type"] == "run.delivery"],
"model_calls": len(model.calls),
"evaluator_calls": len(evaluator_calls),
}
@pytest.fixture(autouse=True)
def _no_network(monkeypatch):
def deny(*args, **kwargs):
raise AssertionError("this regression must not use the network")
monkeypatch.setattr(socket.socket, "connect", deny)
@pytest.mark.asyncio
async def test_recursion_cap_reports_a_plain_stop_with_unpresented_outputs(tmp_path):
result = await _run_until_recursion_cap(tmp_path, with_goal=False)
record = result["record"]
assert record.status == RunStatus.error
assert record.stop_reason == "recursion_capped"
produced = [f"/mnt/user-data/outputs/part-{n}.md" for n in range(1, result["model_calls"] + 1)]
assert result["model_calls"] >= 1
assert record.error == (f"The run stopped at its step limit (recursion_limit=6) before finishing. Created but not presented: {', '.join(produced)}. Send a message to continue.")
assert "config key" not in record.error and "troubleshooting" not in record.error
assert result["error_frames"] == [{"message": record.error, "name": "GraphRecursionError"}]
# One scan feeds both the message and the delivery receipt.
assert len(result["deliveries"]) == 1
assert result["deliveries"][0]["produced_paths"] == produced
assert result["deliveries"][0]["satisfied"] is False
@pytest.mark.asyncio
async def test_recursion_cap_stands_the_goal_down_without_an_evaluator_call(tmp_path):
result = await _run_until_recursion_cap(tmp_path, with_goal=True)
record, goal = result["record"], result["goal"]
assert result["evaluator_calls"] == 0
assert goal is not None and goal["status"] == "active"
evaluation = goal["last_evaluation"]
assert evaluation["stand_down_reason"] == "recursion_capped"
assert evaluation["blocker"] == "run_failed"
assert evaluation["reason"] == "The run reached its step limit before finishing."
assert goal["continuation_count"] == 0
assert record.goal_verdict is not None
assert record.goal_verdict["stand_down_reason"] == "recursion_capped"
assert json.dumps(record.goal_verdict) # stored as plain data on the run record
def test_recursion_cap_message_without_outputs_or_limit():
assert worker._recursion_capped_message(None, None) == "The run stopped at its step limit before finishing. Send a message to continue."
presented = {"produced_paths": ["/mnt/user-data/outputs/a.md"], "matched_paths": ["/mnt/user-data/outputs/a.md"]}
assert worker._recursion_capped_message(100, presented) == "The run stopped at its step limit (recursion_limit=100) before finishing. Send a message to continue."
def test_recursion_cap_message_lists_at_most_five_outputs():
produced = [f"/mnt/user-data/outputs/{n}.md" for n in range(7)]
message = worker._recursion_capped_message(1000, {"produced_paths": produced, "matched_paths": []})
assert message.endswith(f"Created but not presented: {', '.join(produced[:5])} and 2 more. Send a message to continue.")