mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(loop-detection): let a warned agent save collected results before its final answer (#6804)
* fix(loop-detection): let a warned agent save collected results before its final answer The warn-level loop messages told the agent to "Stop calling tools and produce your final answer now." Subagents read this literally: one whose task names an output file stops all tool calls, including the single write that delivers its result, and finishes as "completed" with nothing on disk. Only the two warn-level texts change. They now ask the agent to stop repeating the looping call and wrap up, first saving what it has collected through a file-writing tool if its task asks for a file. The per-tool warning names the looping tool. Thresholds and the hard-stop path (which strips tool calls) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loop-detection): bound final-save recovery after warnings * docs(middleware): keep inherited guidance within CI budget --------- Co-authored-by: huangzz <110116@nd.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
a5e71751d7
commit
80a71b0904
@ -2309,6 +2309,8 @@ Each task gets its own execution environment with a full filesystem view — ski
|
||||
|
||||
The read-before-write gate ties each read mark to that `read_file` call's result, including custom tools returning multi-message `Command` updates. An unrelated result cannot authorize a write after a failed read or hide a successful read.
|
||||
|
||||
Loop warnings let agents save collected results before their final answer. If the save is blocked by the read-before-write gate, the agent may read the target file and retry the save once. A looping or stopped tool must be replaced by an available alternative; if no permitted save path exists or the retry fails, the final answer includes the collected results and reports that the file was not saved. Recovery calls still count toward the loop limits, and a hard stop prevents further tool execution.
|
||||
|
||||
Ranged `read_file` calls count lines the same way on every sandbox provider: a line ends only at a newline. On E2B, BoxLite, Tenki, and OpenSandbox, a file with bare carriage returns (such as a saved progress log), form feeds, or Unicode line separators therefore returns the same lines as on the local sandbox, and the `start_line` a truncated read suggests points at the next unread line.
|
||||
|
||||
Concurrent reads and writes to the same file share a gate across synchronous and asynchronous tool calls. Async callers waiting for that gate do not occupy worker threads needed to finish the current read or write. Cancelling a waiting call leaves the current operation running; a call that already started file inspection still waits for that work to finish before releasing its gate.
|
||||
|
||||
@ -128,6 +128,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
Capture/retain delegations by `(run_id, tool_call_id)`. Later-run replies cannot close older orphans; missing run IDs match conservatively.
|
||||
32. **LoopDetectionMiddleware** - `loop_detection.enabled`: detects repeated tool-call loops. Hard stops clear structured/raw/content-block calls, force a final text answer, and stamp `loop_capped` via `consume_stop_reason` (#3875 Phase 2, matching `TokenBudgetMiddleware`). Persist first warned-state transitions per call hash/tool-frequency burst and hard stops as `middleware:loop_detection`, with `is_subagent`/optional `agent_id`; omit arguments, message content, results and argument-derived hashes. Ordinary task subagents use dedicated recorder keys via a parent-loop proxy; never pass `RunJournal` into their isolated loop. Durable batch subagents have no parent journal and persist no transitions.
|
||||
New user runs reset budgets/history; same-run goal continuations share it.
|
||||
Warnings allow a final save and one read-before-write retry through non-looping tools; otherwise report unsaved results. Hard stops never permit saves.
|
||||
Isolate sibling warnings and keep lifecycle hooks topology-stable. Before changing this guard, read
|
||||
[Loop detection lifecycle](../../../../../docs/LOOP_DETECTION.md) for
|
||||
fallback identity, cleanup/LRU/reset, severity ordering, and test invariants.
|
||||
|
||||
@ -227,12 +227,28 @@ def _hash_tool_calls(tool_calls: list[dict]) -> str:
|
||||
return hashlib.md5(blob.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
_WARNING_MSG = "[LOOP DETECTED] You are repeating the same tool calls. Stop calling tools and produce your final answer now. If you cannot complete the task, summarize what you accomplished so far."
|
||||
|
||||
_TOOL_FREQ_WARNING_MSG = (
|
||||
"[LOOP DETECTED] You have called {tool_name} {count} times without producing a final answer. Stop calling tools and produce your final answer now. If you cannot complete the task, summarize what you accomplished so far."
|
||||
# Warn-level messages ask the agent to wrap up, but must not make it drop work
|
||||
# it was asked to persist: a subagent whose task names an output file would
|
||||
# otherwise stop *all* tool calls, including the write that delivers its
|
||||
# result, and finish as "completed" with nothing on disk. So the wrap-up
|
||||
# allows a bounded save/read/retry before the final answer, without reusing
|
||||
# a looping writer or bypassing the read-before-write gate. The hard-stop
|
||||
# path below is unchanged (it strips tool calls).
|
||||
_WRAP_UP_INSTRUCTIONS = (
|
||||
"If your task asks you to save results to a file, first write what you have collected so far to that file "
|
||||
"with a file-writing tool (for example write_file, unless it is the looping tool). "
|
||||
"If the writer is itself looping, use a different available file-writing tool. "
|
||||
"If a save is blocked by a read-before-write check, read the target file and retry the save once. "
|
||||
"Do not use a tool that this warning tells you to stop, or repeat a failed save loop. "
|
||||
"If no permitted save path is available or the retry fails, include the collected results in your final answer "
|
||||
"and state that the file was not saved. Then stop calling tools and produce your final answer. "
|
||||
"If you cannot complete the task, summarize what you accomplished so far."
|
||||
)
|
||||
|
||||
_WARNING_MSG = "[LOOP DETECTED] You are repeating the same tool calls. Stop repeating them and wrap up now. " + _WRAP_UP_INSTRUCTIONS
|
||||
|
||||
_TOOL_FREQ_WARNING_MSG = "[LOOP DETECTED] You have called {tool_name} {count} times without producing a final answer. Stop calling {tool_name} and wrap up now. " + _WRAP_UP_INSTRUCTIONS
|
||||
|
||||
_HARD_STOP_MSG = "[FORCED STOP] Repeated tool calls exceeded the safety limit. Producing final answer with results collected so far."
|
||||
|
||||
_TOOL_FREQ_HARD_STOP_MSG = "[FORCED STOP] Tool {tool_name} called {count} times — exceeded the per-tool safety limit. Producing final answer with results collected so far."
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import copy
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@ -2319,3 +2320,131 @@ class TestFromConfig:
|
||||
result = mw._apply(_make_state(tool_calls=[_bash_call(f"cmd_{hard}")]), runtime)
|
||||
assert result is not None
|
||||
assert mw.consume_stop_reason("test-run") == "loop_capped"
|
||||
|
||||
|
||||
class TestWarningLetsAgentSaveResultsFirst:
|
||||
"""F67: a warn-level loop message must not make a subagent drop the
|
||||
file it was asked to write. The warning asks for one last save through a
|
||||
file-writing tool before the final answer; the hard stop is unchanged."""
|
||||
|
||||
@staticmethod
|
||||
def _write_call(path="/mnt/user-data/workspace/out.md"):
|
||||
return {"name": "write_file", "id": "call_write", "args": {"path": path, "content": "partial results"}}
|
||||
|
||||
@staticmethod
|
||||
def _read_call(path):
|
||||
return {"name": "read_file", "id": f"call_read_{path}", "args": {"path": path}}
|
||||
|
||||
def test_hash_warning_asks_to_save_before_final_answer(self):
|
||||
mw = LoopDetectionMiddleware(warn_threshold=3, hard_limit=10)
|
||||
runtime = _make_runtime()
|
||||
for _ in range(3):
|
||||
mw._apply(_make_state(tool_calls=[_bash_call("ls")]), runtime)
|
||||
warning = mw._pending_warnings[_pending_key()][0]
|
||||
assert "LOOP DETECTED" in warning
|
||||
assert "Stop calling tools and produce your final answer now" not in warning
|
||||
assert "write_file" in warning
|
||||
assert warning.index("write what you have collected") < warning.index("produce your final answer")
|
||||
|
||||
def test_freq_warning_names_the_looping_tool_and_allows_saving(self):
|
||||
mw = LoopDetectionMiddleware(tool_freq_warn=3, tool_freq_hard_limit=10)
|
||||
runtime = _make_runtime()
|
||||
for i in range(3):
|
||||
mw._apply(_make_state(tool_calls=[self._read_call(f"/f_{i}")]), runtime)
|
||||
warning = mw._pending_warnings[_pending_key()][0]
|
||||
assert "Stop calling read_file" in warning
|
||||
assert "Stop calling tools and produce your final answer now" not in warning
|
||||
assert "write_file" in warning
|
||||
|
||||
def test_save_call_after_warning_is_not_stripped(self):
|
||||
mw = LoopDetectionMiddleware(tool_freq_warn=3, tool_freq_hard_limit=10)
|
||||
runtime = _make_runtime()
|
||||
for i in range(3):
|
||||
mw._apply(_make_state(tool_calls=[self._read_call(f"/f_{i}")]), runtime)
|
||||
assert mw._pending_warnings[_pending_key()]
|
||||
# The follow-up write is a different tool and a different call set:
|
||||
# neither detection layer acts on it, so the write reaches the tools node.
|
||||
assert mw._apply(_make_state(tool_calls=[self._write_call()]), runtime) is None
|
||||
|
||||
@pytest.mark.parametrize("layer", ["hash", "frequency"])
|
||||
def test_warning_allows_a_bounded_read_before_write_recovery(self, layer):
|
||||
mw = LoopDetectionMiddleware(warn_threshold=3 if layer == "hash" else 10, hard_limit=20, tool_freq_warn=3 if layer == "frequency" else 10, tool_freq_hard_limit=20)
|
||||
runtime = _make_runtime()
|
||||
for i in range(3):
|
||||
mw._apply(_make_state(tool_calls=[_bash_call("ls" if layer == "hash" else f"cmd_{i}")]), runtime)
|
||||
warning = mw._pending_warnings[_pending_key()][0]
|
||||
assert "read-before-write" in warning
|
||||
assert "read the target file" in warning
|
||||
assert "retry the save once" in warning
|
||||
assert warning.index("retry the save once") < warning.index("produce your final answer")
|
||||
|
||||
@pytest.mark.parametrize("layer", ["hash", "frequency"])
|
||||
def test_looping_writer_requires_an_alternative_or_an_unsaved_result(self, layer):
|
||||
mw = LoopDetectionMiddleware(warn_threshold=3 if layer == "hash" else 10, hard_limit=20, tool_freq_warn=3 if layer == "frequency" else 10, tool_freq_hard_limit=20)
|
||||
runtime = _make_runtime()
|
||||
for i in range(3):
|
||||
mw._apply(_make_state(tool_calls=[self._write_call("/out.md" if layer == "hash" else f"/out_{i}.md")]), runtime)
|
||||
warning = mw._pending_warnings[_pending_key()][0]
|
||||
assert "unless it is the looping tool" in warning
|
||||
assert "different available file-writing tool" in warning
|
||||
assert "Do not use a tool that this warning tells you to stop" in warning
|
||||
assert "include the collected results in your final answer" in warning
|
||||
assert "file was not saved" in warning
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warned_agent_can_recover_an_existing_file_save_through_the_real_gate(self, tmp_path):
|
||||
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
||||
|
||||
output = tmp_path / "out.md"
|
||||
output.write_text("previous attempt", encoding="utf-8")
|
||||
executed = []
|
||||
|
||||
@as_tool
|
||||
def bash(command: str) -> str:
|
||||
"""Return a deterministic research result without running a shell."""
|
||||
return "partial results"
|
||||
|
||||
@as_tool
|
||||
def read_file(path: str) -> str:
|
||||
"""Read the output from the previous attempt."""
|
||||
content = Path(path).read_text(encoding="utf-8")
|
||||
executed.append(("read", content))
|
||||
return content
|
||||
|
||||
@as_tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Save the collected results."""
|
||||
Path(path).write_text(content, encoding="utf-8")
|
||||
executed.append(("write", content))
|
||||
return "saved"
|
||||
|
||||
loop = LoopDetectionMiddleware(warn_threshold=3, hard_limit=10, tool_freq_warn=10, tool_freq_hard_limit=20)
|
||||
gate = ReadBeforeWriteMiddleware(content_reader=lambda _runtime, path: Path(path).read_text(encoding="utf-8"))
|
||||
calls = [dict(_bash_call("ls"), id=f"research_{i}") for i in range(3)]
|
||||
calls.extend([dict(self._write_call(str(output)), id="blocked_save"), self._read_call(str(output)), dict(self._write_call(str(output)), id="final_save")])
|
||||
model = _CapturingFakeMessagesListChatModel(responses=[*[AIMessage(content="", tool_calls=[call]) for call in calls], AIMessage(content="Saved partial results.")])
|
||||
graph = create_agent(model=model, tools=[bash, read_file, write_file], middleware=[gate, loop])
|
||||
result = await graph.ainvoke({"messages": [("user", f"Save your research to {output}.")]}, context={"thread_id": "save-thread", "run_id": "save-run"}, config={"recursion_limit": 30})
|
||||
|
||||
warnings = [message for message in model.seen_messages[3] if isinstance(message, HumanMessage) and message.name == "loop_warning"]
|
||||
assert len(warnings) == 1 and "retry the save once" in warnings[0].content
|
||||
blocked = next(message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "blocked_save")
|
||||
assert blocked.status == "error" and "deerflow_write_block" in blocked.additional_kwargs
|
||||
read = next(message for message in result["messages"] if isinstance(message, ToolMessage) and message.name == "read_file")
|
||||
assert read.additional_kwargs["deerflow_read_mark"]["path"] == str(output)
|
||||
assert executed == [("read", "previous attempt"), ("write", "partial results")]
|
||||
assert output.read_text(encoding="utf-8") == "partial results"
|
||||
assert result["messages"][-1].content == "Saved partial results."
|
||||
assert loop.consume_stop_reason("save-run") is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,expected",
|
||||
[
|
||||
("_HARD_STOP_MSG", "[FORCED STOP] Repeated tool calls exceeded the safety limit. Producing final answer with results collected so far."),
|
||||
("_TOOL_FREQ_HARD_STOP_MSG", "[FORCED STOP] Tool {tool_name} called {count} times — exceeded the per-tool safety limit. Producing final answer with results collected so far."),
|
||||
],
|
||||
)
|
||||
def test_hard_stop_message_unchanged(self, name, expected):
|
||||
message = getattr(loop_detection_module, name)
|
||||
assert message == expected
|
||||
assert loop_detection_module._WRAP_UP_INSTRUCTIONS not in message
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user