Skip to content

write_file silently produces zero-byte files when content arg is missing #19096

@centripetal-star

Description

@centripetal-star

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:

  1. {"path": "..."} (no content) — returns error, no file created ✓
  2. {"content": "..."} (no path) — returns error ✓
  3. {"path": "...", "content": ""} (explicit empty) — creates 0-byte file ✓
  4. {"path": "...", "content": "hello"} (normal) — writes correctly ✓
  5. {"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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/toolsTool registry, model_tools, toolsetstool/fileFile tools (read, write, patch, search)type/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions