write_file silently produces zero-byte files when content arg is missing
Summary
tools/file_tools.py::_handle_write_file passes args.get("content", "") into the underlying tool, so a malformed tool call that omits the content key entirely is silently treated as content="". The tool reports success — {"bytes_written": 0, "dirs_created": true} — and creates a zero-byte file. The schema declares content as required, but the handler never enforces it.
This produces a hard-to-debug failure mode where the model is convinced its file write succeeded, the file exists on disk, and downstream steps (read-after-write, diff, lint, deploy) appear normal until someone notices the file is empty.
Reproduction
from tools.file_tools import _handle_write_file
# Dropped `content` arg — schema says it's required, handler silently
# substitutes "".
result = _handle_write_file({"path": "/tmp/oops.md"})
print(result)
# {"bytes_written": 0, "dirs_created": true}
import os
print(os.path.exists("/tmp/oops.md"), os.path.getsize("/tmp/oops.md"))
# True 0
Root cause
tools/file_tools.py line ~1100:
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,
)
The schema:
WRITE_FILE_SCHEMA = {
"name": "write_file",
...
"parameters": {
...
"required": ["path", "content"],
},
}
Schema requirement is decorative — the handler defaults the missing field instead of refusing.
Why this matters in practice
This bug is benign in normal operation. It bites under context pressure.
Deep into the working context (system prompt + injected memory + skill catalog + accumulated tool turns + large prior tool results), some frontier models start emitting tool calls with the small args (path) intact and the large args (multi-KB content strings) dropped entirely. The JSON itself parses fine — it just has the high-cost field omitted. We have observed this happening repeatedly on a single session: 7 consecutive write_file calls came through with shape {"path": "..."}, all of which the handler accepted and "succeeded" on, producing seven zero-byte files. The model only escaped the loop by switching to execute_code with from hermes_tools import write_file — which packages the entire write into a single code arg, sidestepping the dropout.
The same dropout pattern also showed up on terminal (empty command) and execute_code (empty code) in the same window, with similarly forgiving fallbacks ("" and None). Those at least returned errors ("Invalid command: expected string, got NoneType" / "No code provided."), which the model could read and recover from. write_file was uniquely silent because zero-byte writes return success.
Fix
Reject tool calls that omit required fields entirely, while still allowing explicit empty strings for legitimate file truncation.
def _handle_write_file(args, **kw):
tid = kw.get("task_id") or "default"
if "path" not in args or not isinstance(args.get("path"), str) or not args.get("path"):
return tool_error(
"write_file: missing required field 'path'. The tool call did "
"not include a path argument. 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.get("content"), str):
return tool_error(
"write_file: 'content' must be a string. Got "
f"{type(args.get('content')).__name__}."
)
return write_file_tool(path=args["path"], content=args["content"], task_id=tid)
Verified locally with five cases:
{"path": "..."} (no content) — returns error, no file created ✓
{"content": "..."} (no path) — returns error ✓
{"path": "...", "content": ""} (explicit empty) — creates 0-byte file ✓
{"path": "...", "content": "hello"} (normal) — writes correctly ✓
{"path": "...", "content": {...}} (wrong type) — returns error ✓
The distinction between "key missing" and "key present, empty string" preserves the legitimate truncate-a-file workflow while making the silent-success bug impossible.
Happy to open a PR with this change plus a regression test if useful.
write_filesilently produces zero-byte files whencontentarg is missingSummary
tools/file_tools.py::_handle_write_filepassesargs.get("content", "")into the underlying tool, so a malformed tool call that omits thecontentkey entirely is silently treated ascontent="". The tool reports success —{"bytes_written": 0, "dirs_created": true}— and creates a zero-byte file. The schema declarescontentas required, but the handler never enforces it.This produces a hard-to-debug failure mode where the model is convinced its file write succeeded, the file exists on disk, and downstream steps (read-after-write, diff, lint, deploy) appear normal until someone notices the file is empty.
Reproduction
Root cause
tools/file_tools.pyline ~1100:The schema:
Schema requirement is decorative — the handler defaults the missing field instead of refusing.
Why this matters in practice
This bug is benign in normal operation. It bites under context pressure.
Deep into the working context (system prompt + injected memory + skill catalog + accumulated tool turns + large prior tool results), some frontier models start emitting tool calls with the small args (
path) intact and the large args (multi-KBcontentstrings) dropped entirely. The JSON itself parses fine — it just has the high-cost field omitted. We have observed this happening repeatedly on a single session: 7 consecutivewrite_filecalls came through with shape{"path": "..."}, all of which the handler accepted and "succeeded" on, producing seven zero-byte files. The model only escaped the loop by switching toexecute_codewithfrom hermes_tools import write_file— which packages the entire write into a singlecodearg, sidestepping the dropout.The same dropout pattern also showed up on
terminal(emptycommand) andexecute_code(emptycode) in the same window, with similarly forgiving fallbacks (""andNone). Those at least returned errors ("Invalid command: expected string, got NoneType"/"No code provided."), which the model could read and recover from.write_filewas uniquely silent because zero-byte writes return success.Fix
Reject tool calls that omit required fields entirely, while still allowing explicit empty strings for legitimate file truncation.
Verified locally with five cases:
{"path": "..."}(no content) — returns error, no file created ✓{"content": "..."}(no path) — returns error ✓{"path": "...", "content": ""}(explicit empty) — creates 0-byte file ✓{"path": "...", "content": "hello"}(normal) — writes correctly ✓{"path": "...", "content": {...}}(wrong type) — returns error ✓The distinction between "key missing" and "key present, empty string" preserves the legitimate truncate-a-file workflow while making the silent-success bug impossible.
Happy to open a PR with this change plus a regression test if useful.