{"repo":"NousResearch/hermes-agent","public_id":"19096","display_label":"Validate required args in file and todo tools","summary":"These issues and fixes address missing or empty argument validation across file tools (read_file, write_file) and the todo tool, where absent or empty inputs were silently accepted and produced corrupt or zero-byte output. The changes add upfront rejection of invalid arguments instead of silent substitution or misleading errors.","subsystem":"agent","counts":{"members":12,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":2,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19284},"resolution":{"state":"partially_resolved","by":"pr-19284,pr-133864"},"freshness":{"state":"verified","last_verified_at":"2026-10-06T13:53:36+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14727272727272728},"fix_edges":[{"issue_number":19096,"pr_number":19284,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44496,"pr_number":133864,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":19096,"title":"write_file silently produces zero-byte files when content arg is missing","url":null,"state":"closed"},{"kind":"issue","number":31791,"title":"Bug: read_file produces confusing error when called with empty/missing path","url":null,"state":"open"},{"kind":"issue","number":44496,"title":"todo_tool: silent substitution of '(no description)' for empty content corrupts todo list","url":null,"state":"closed"},{"kind":"pr","number":19097,"title":"fix(tools): guard write_file against missing required args","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e527240b2","number":0},{"kind":"pr","value":"#19096","number":19096}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19119,"title":"fix(tools): write_file rejects missing 'content'/'path' instead of creating zero-byte files (#19096)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19097","number":19097}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19284,"title":"fix(tools): write_file rejects missing 'content'/'path' instead of creating zero-byte files","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_file_tools.py","tools/file_tools.py"],"fix_hunks":[{"file":"tests/tools/test_file_tools.py","hunks":["@@ -104,6 +104,44 @@ def test_unexpected_exception_still_logs_error(self, mock_get, caplog):\n         assert result[\"error\"] == \"boom\"\n         assert any(\"write_file error\" in r.getMessage() for r in caplog.records)\n \n+    def test_missing_content_key_returns_error(self):\n+        \"\"\"#19096 — handler must reject tool calls where 'content' key is absent.\"\"\"\n+        from tools.file_tools import _handle_write_file\n+\n+        result = json.loads(_handle_write_file({\"path\": \"/tmp/oops.md\"}))\n+        assert \"error\" in result\n+        assert \"content\" in result[\"error\"]\n+        assert \"path\" not in result.get(\"error\", \"\").lower() or \"missing\" not in result.get(\"error\", \"\").lower() or True  # just check error present\n+\n+    def test_missing_path_key_returns_error(self):\n+        \"\"\"#19096 — handler must reject tool calls where 'path' key is absent.\"\"\"\n+        from tools.file_tools import _handle_write_file\n+\n+        result = json.loads(_handle_write_file({\"content\": \"hello\"}))\n+        assert \"error\" in result\n+\n+    def test_explicit_empty_content_is_allowed(self):\n+        \"\"\"#19096 — explicit empty string content (file truncation) must still work.\"\"\"\n+        from tools.file_tools import _handle_write_file\n+\n+        with patch(\"tools.file_tools._get_file_ops\") as mock_get:\n+            mock_ops = MagicMock()\n+            result_obj = MagicMock()\n+            result_obj.to_dict.return_value = {\"status\": \"ok\", \"path\": \"/tmp/empty.txt\", \"bytes\": 0}\n+            mock_ops.write_file.return_value = result_obj\n+            mock_get.return_value = mock_ops\n+\n+            result = json.loads(_handle_write_file({\"path\": \"/tmp/empty.txt\", \"content\": \"\"}))\n+            assert result[\"status\"] == \"ok\"\n+\n+    def test_non_string_content_returns_error(self):\n+        \"\"\"#19096 — content must be a string, not a dict or list.\"\"\"\n+        from tools.file_tools import _handle_write_file\n+\n+        result = json.loads(_handle_write_file({\"path\": \"/tmp/x.txt\", \"content\": {\"nested\": \"dict\"}}))\n+        assert \"error\" in result\n+        assert \"string\" in result[\"error\"].lower() or \"content\" in result[\"error\"].lower()\n+\n \n class TestPatchHandler:\n     @patch(\"tools.file_tools._get_file_ops\")"]},{"file":"tools/file_tools.py","hunks":["@@ -1097,7 +1097,25 @@ def _handle_read_file(args, **kw):\n \n def _handle_write_file(args, **kw):\n     tid = kw.get(\"task_id\") or \"default\"\n-    return write_file_tool(path=args.get(\"path\", \"\"), content=args.get(\"content\", \"\"), task_id=tid)\n+    if not args.get(\"path\") or not isinstance(args.get(\"path\"), str):\n+        return tool_error(\n+            \"write_file: missing required field 'path'. Re-emit the tool call with \"\n+            \"both 'path' and 'content' set.\"\n+        )\n+    if \"content\" not in args:\n+        return tool_error(\n+            \"write_file: missing required field 'content'. The tool call included a \"\n+            \"path but no content argument — this is almost always a dropped-arg bug \"\n+            \"under context pressure. Re-emit the tool call with the full content \"\n+            \"payload, or use execute_code with hermes_tools.write_file() for very \"\n+            \"large files.\"\n+        )\n+    if not isinstance(args[\"content\"], str):\n+        return tool_error(\n+            f\"write_file: 'content' must be a string, got \"\n+            f\"{type(args['content']).__name__}.\"\n+        )\n+    return write_file_tool(path=args[\"path\"], content=args[\"content\"], task_id=tid)\n \n \n def _handle_patch(args, **kw):\n"]}]},{"kind":"pr","number":31801,"title":"fix(file_tools): validate read_file path before dispatch (#31791)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":31878,"title":"fix(file-tools): reject empty read_file path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31801","number":31801},{"kind":"pr","value":"#31791","number":31791}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44498,"title":"fix(todo): reject empty content instead of substituting (no description) (#44496)","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":83712,"title":"fix(todo): reject empty content instead of silent placeholder","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44498","number":44498},{"kind":"pr","value":"#44496","number":44496},{"kind":"pr","value":"#44498","number":44498}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":106948,"title":"fix(tools): reject read_file calls with missing or empty path","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#31801","number":31801},{"kind":"pr","value":"#31791","number":31791}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":133864,"title":"fix(todo): reject empty content instead of substituting '(no description)'","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_todo_tool.py","tools/todo_tool.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}