diff --git a/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py index 6026d834e..5fcc238fe 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py @@ -26,6 +26,11 @@ from langchain_core.messages import ToolMessage logger = logging.getLogger(__name__) +# Workaround for issue #2894: malformed write_file calls can carry huge Markdown +# payloads in invalid tool-call args. Keep recovery error details short so the +# synthetic ToolMessage does not echo large or malformed content back to the model. +_MAX_RECOVERY_ERROR_DETAIL_LEN = 500 + class DanglingToolCallMiddleware(AgentMiddleware[AgentState]): """Inserts placeholder ToolMessages for dangling tool calls before model invocation. @@ -98,9 +103,25 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]): @staticmethod def _synthetic_tool_message_content(tool_call: dict) -> str: if tool_call.get("invalid"): + name = tool_call.get("name") error = tool_call.get("error") - if isinstance(error, str) and error: - return f"[Tool call could not be executed because its arguments were invalid: {error}]" + error_text = error[:_MAX_RECOVERY_ERROR_DETAIL_LEN] if isinstance(error, str) and error else "" + # Workaround for issue #2894: malformed write_file calls can carry huge Markdown + # payloads in invalid tool-call args. Keep recovery guidance actionable without + # echoing large or malformed content back to the model. + if name == "write_file": + details = f" Parser error: {error_text}" if error_text else "" + return ( + "[write_file failed before execution: the tool-call arguments were not valid JSON, " + "so no file was written. This often happens when the model tries to write a very " + "large Markdown file in a single tool call, especially when `content` contains " + "unescaped quotes, inline JSON, backslashes, or code fences. Do not retry the same " + "large `write_file` payload for this artifact; provide the report/content directly " + "as normal assistant text in your next response. If a file write is still needed " + f"later, split the file into smaller sections instead of one large payload.{details}]" + ) + if error_text: + return f"[Tool call could not be executed because its arguments were invalid: {error_text}]" return "[Tool call could not be executed because its arguments were invalid.]" return "[Tool call was interrupted and did not return a result.]" diff --git a/backend/tests/test_dangling_tool_call_middleware.py b/backend/tests/test_dangling_tool_call_middleware.py index 34f1ac035..2ccce9fa9 100644 --- a/backend/tests/test_dangling_tool_call_middleware.py +++ b/backend/tests/test_dangling_tool_call_middleware.py @@ -333,8 +333,27 @@ class TestBuildPatchedMessagesPatching: assert patched[1].tool_call_id == "write_file:36" assert patched[1].name == "write_file" assert patched[1].status == "error" + assert "write_file failed before execution" in patched[1].content + assert "no file was written" in patched[1].content + assert "very large Markdown file in a single tool call" in patched[1].content + assert "Do not retry the same large `write_file` payload" in patched[1].content + assert "split the file into smaller sections" in patched[1].content + assert "normal assistant text" in patched[1].content + assert "Failed to parse tool arguments" in patched[1].content + assert 'bad {"json"}' not in patched[1].content + + def test_non_write_file_invalid_tool_call_uses_generic_recovery_message(self): + mw = DanglingToolCallMiddleware() + msgs = [_ai_with_invalid_tool_calls([_invalid_tc(name="search", tc_id="search:1")])] + + patched = mw._build_patched_messages(msgs) + + assert patched is not None + assert patched[1].tool_call_id == "search:1" + assert patched[1].name == "search" assert "arguments were invalid" in patched[1].content assert "Failed to parse tool arguments" in patched[1].content + assert "write_file failed before execution" not in patched[1].content def test_valid_and_invalid_tool_calls_are_both_patched(self): mw = DanglingToolCallMiddleware()