diff --git a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py index 5f68a3ce6..8bc4cfd43 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @@ -182,6 +182,23 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): return None, False + @staticmethod + def _append_text(content: str | list | None, text: str) -> str | list: + """Append *text* to AIMessage content, handling str, list, and None. + + When content is a list of content blocks (e.g. Anthropic thinking mode), + we append a new ``{"type": "text", ...}`` block instead of concatenating + a string to a list, which would raise ``TypeError``. + """ + if content is None: + return text + if isinstance(content, list): + return [*content, {"type": "text", "text": f"\n\n{text}"}] + if isinstance(content, str): + return content + f"\n\n{text}" + # Fallback: coerce unexpected types to str to avoid TypeError + return str(content) + f"\n\n{text}" + def _apply(self, state: AgentState, runtime: Runtime) -> dict | None: warning, hard_stop = self._track_and_check(state, runtime) @@ -192,7 +209,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): stripped_msg = last_msg.model_copy( update={ "tool_calls": [], - "content": (last_msg.content or "") + f"\n\n{_HARD_STOP_MSG}", + "content": self._append_text(last_msg.content, _HARD_STOP_MSG), } ) return {"messages": [stripped_msg]} diff --git a/backend/tests/test_loop_detection_middleware.py b/backend/tests/test_loop_detection_middleware.py index 3bd0c3665..e037b8492 100644 --- a/backend/tests/test_loop_detection_middleware.py +++ b/backend/tests/test_loop_detection_middleware.py @@ -1,5 +1,6 @@ """Tests for LoopDetectionMiddleware.""" +import copy from unittest.mock import MagicMock from langchain_core.messages import AIMessage, HumanMessage, SystemMessage @@ -19,8 +20,13 @@ def _make_runtime(thread_id="test-thread"): def _make_state(tool_calls=None, content=""): - """Build a minimal AgentState dict with an AIMessage.""" - msg = AIMessage(content=content, tool_calls=tool_calls or []) + """Build a minimal AgentState dict with an AIMessage. + + Deep-copies *content* when it is mutable (e.g. list) so that + successive calls never share the same object reference. + """ + safe_content = copy.deepcopy(content) if isinstance(content, list) else content + msg = AIMessage(content=safe_content, tool_calls=tool_calls or []) return {"messages": [msg]} @@ -229,3 +235,114 @@ class TestLoopDetection: mw._apply(_make_state(tool_calls=call), runtime) assert "default" in mw._history + + +class TestAppendText: + """Unit tests for LoopDetectionMiddleware._append_text.""" + + def test_none_content_returns_text(self): + result = LoopDetectionMiddleware._append_text(None, "hello") + assert result == "hello" + + def test_str_content_concatenates(self): + result = LoopDetectionMiddleware._append_text("existing", "appended") + assert result == "existing\n\nappended" + + def test_empty_str_content_concatenates(self): + result = LoopDetectionMiddleware._append_text("", "appended") + assert result == "\n\nappended" + + def test_list_content_appends_text_block(self): + """List content (e.g. Anthropic thinking mode) should get a new text block.""" + content = [ + {"type": "thinking", "text": "Let me think..."}, + {"type": "text", "text": "Here is my answer"}, + ] + result = LoopDetectionMiddleware._append_text(content, "stop msg") + assert isinstance(result, list) + assert len(result) == 3 + assert result[0] == content[0] + assert result[1] == content[1] + assert result[2] == {"type": "text", "text": "\n\nstop msg"} + + def test_empty_list_content_appends_text_block(self): + result = LoopDetectionMiddleware._append_text([], "stop msg") + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == {"type": "text", "text": "\n\nstop msg"} + + def test_unexpected_type_coerced_to_str(self): + """Unexpected content types should be coerced to str as a fallback.""" + result = LoopDetectionMiddleware._append_text(42, "stop msg") + assert isinstance(result, str) + assert result == "42\n\nstop msg" + + def test_list_content_not_mutated_in_place(self): + """_append_text must not modify the original list.""" + original = [{"type": "text", "text": "hello"}] + result = LoopDetectionMiddleware._append_text(original, "appended") + assert len(original) == 1 # original unchanged + assert len(result) == 2 # new list has the appended block + + +class TestHardStopWithListContent: + """Regression tests: hard stop must not crash when AIMessage.content is a list.""" + + def test_hard_stop_with_list_content(self): + """Hard stop on list content should not raise TypeError (regression).""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + # Build state with list content (e.g. Anthropic thinking mode) + list_content = [ + {"type": "thinking", "text": "Let me think..."}, + {"type": "text", "text": "I'll run ls"}, + ] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call, content=list_content), runtime) + + # Fourth call triggers hard stop — must not raise TypeError + result = mw._apply(_make_state(tool_calls=call, content=list_content), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg, AIMessage) + assert msg.tool_calls == [] + # Content should remain a list with the stop message appended + assert isinstance(msg.content, list) + assert len(msg.content) == 3 + assert msg.content[2]["type"] == "text" + assert _HARD_STOP_MSG in msg.content[2]["text"] + + def test_hard_stop_with_none_content(self): + """Hard stop on None content should produce a plain string.""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call), runtime) + + # Fourth call with default empty-string content + result = mw._apply(_make_state(tool_calls=call), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg.content, str) + assert _HARD_STOP_MSG in msg.content + + def test_hard_stop_with_str_content(self): + """Hard stop on str content should concatenate the stop message.""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call, content="thinking..."), runtime) + + result = mw._apply(_make_state(tool_calls=call, content="thinking..."), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg.content, str) + assert msg.content.startswith("thinking...") + assert _HARD_STOP_MSG in msg.content