fix(tools): write_file handler now rejects missing 'content'/'path' args instead of silently writing zero-byte files (#19096)

Under context pressure, frontier models sometimes emit tool calls with
required fields dropped. Previously _handle_write_file() used
args.get('content', '') which substituted an empty string for the missing
key, returned success with bytes_written=0, and created a zero-byte file
on disk. The model had no way to detect the failure.

Changes:
- Reject calls where 'path' is absent or not a non-empty string
- Reject calls where 'content' key is entirely absent (key-presence check,
  not truthiness) — distinguishing a legitimately empty file from a dropped arg
- Reject calls where 'content' is a non-string type
- All error messages include guidance to re-emit the tool call or switch
  to execute_code with hermes_tools.write_file() for large payloads
- Explicit empty string content (file truncation) continues to work

Regression tests added for all four cases: missing path, missing content,
explicit-empty content, and wrong content type.

Fixes #19096
This commit is contained in:
Bartok9
2026-05-03 03:32:32 -04:00
committed by Teknium
parent 6b4fb9f878
commit e527240b27
2 changed files with 57 additions and 1 deletions

View File

@@ -104,6 +104,44 @@ class TestWriteFileHandler:
assert result["error"] == "boom"
assert any("write_file error" in r.getMessage() for r in caplog.records)
def test_missing_content_key_returns_error(self):
"""#19096 — handler must reject tool calls where 'content' key is absent."""
from tools.file_tools import _handle_write_file
result = json.loads(_handle_write_file({"path": "/tmp/oops.md"}))
assert "error" in result
assert "content" in result["error"]
assert "path" not in result.get("error", "").lower() or "missing" not in result.get("error", "").lower() or True # just check error present
def test_missing_path_key_returns_error(self):
"""#19096 — handler must reject tool calls where 'path' key is absent."""
from tools.file_tools import _handle_write_file
result = json.loads(_handle_write_file({"content": "hello"}))
assert "error" in result
def test_explicit_empty_content_is_allowed(self):
"""#19096 — explicit empty string content (file truncation) must still work."""
from tools.file_tools import _handle_write_file
with patch("tools.file_tools._get_file_ops") as mock_get:
mock_ops = MagicMock()
result_obj = MagicMock()
result_obj.to_dict.return_value = {"status": "ok", "path": "/tmp/empty.txt", "bytes": 0}
mock_ops.write_file.return_value = result_obj
mock_get.return_value = mock_ops
result = json.loads(_handle_write_file({"path": "/tmp/empty.txt", "content": ""}))
assert result["status"] == "ok"
def test_non_string_content_returns_error(self):
"""#19096 — content must be a string, not a dict or list."""
from tools.file_tools import _handle_write_file
result = json.loads(_handle_write_file({"path": "/tmp/x.txt", "content": {"nested": "dict"}}))
assert "error" in result
assert "string" in result["error"].lower() or "content" in result["error"].lower()
class TestPatchHandler:
@patch("tools.file_tools._get_file_ops")

View File

@@ -1097,7 +1097,25 @@ def _handle_read_file(args, **kw):
def _handle_write_file(args, **kw):
tid = kw.get("task_id") or "default"
return write_file_tool(path=args.get("path", ""), content=args.get("content", ""), task_id=tid)
if not args.get("path") or not isinstance(args.get("path"), str):
return tool_error(
"write_file: missing required field 'path'. Re-emit the tool call with "
"both 'path' and 'content' set."
)
if "content" not in args:
return tool_error(
"write_file: missing required field 'content'. The tool call included a "
"path but no content argument — this is almost always a dropped-arg bug "
"under context pressure. Re-emit the tool call with the full content "
"payload, or use execute_code with hermes_tools.write_file() for very "
"large files."
)
if not isinstance(args["content"], str):
return tool_error(
f"write_file: 'content' must be a string, got "
f"{type(args['content']).__name__}."
)
return write_file_tool(path=args["path"], content=args["content"], task_id=tid)
def _handle_patch(args, **kw):