NousResearch/hermes-agent

read_file line numbers leaking into write_file content

Cluster 4581 · agent. Data as of

4 open items. Fix merged in #50318 on 21 Jun 2026. Check it covers your case.

The short answer

0open issues
4open PRs
11items grouped
Known?
Yes. 11 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #50318. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #50318. That is separate from the fix event.
Still open
0 issues and 4 PRs.

Most members concern read_file output with line-number prefixes being written back through write_file (including in the execute_code sandbox), and propose options such as raw reads, rejecting, or stripping prefixes. One member about pipe-delimited data with a header row appears only loosely related.

Open issues0

No open issues.

Open PRs4

  • PR#42144feat(tools): add add_line_numbers option to read_file
  • PR#46486Fix raw read_file support in execute_code sandbox
  • PR#46503fix(tools): strip line-number prefixes in sandbox write_file to prevent read→write accumulation
  • PR#127457fix(file): let write_file accept pipe-delimited data with a header row

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50318 fix(file): reject read_file line-numbered output in write_file

    merged to main

    files: tests/tools/test_file_read_guards.py, tests/tools/test_file_tools.py, tools/file_tools.py

    Show the diff
    • tests/tools/test_file_read_guards.py
      @@ -275,7 +275,7 @@ def test_write_rejects_internal_read_status_text(self, mock_ops):
               ))
       
               self.assertIn("error", result)
      -        self.assertIn("internal read_file status text", result["error"])
      +        self.assertIn("internal read_file display text", result["error"])
               fake.write_file.assert_not_called()
       
           @patch("tools.file_tools._get_file_ops")
      @@ -299,7 +299,7 @@ def test_write_rejects_status_text_with_small_framing(self, mock_ops):
               ))
       
               self.assertIn("error", result)
      -        self.assertIn("internal read_file status text", result["error"])
      +        self.assertIn("internal read_file display text", result["error"])
               fake.write_file.assert_not_called()
       
           @patch("tools.file_tools._get_file_ops")
    • tests/tools/test_file_tools.py
      @@ -91,6 +91,33 @@ def test_permission_error_returns_error_json_without_error_log(self, mock_get, c
               assert any("write_file expected denial" in r.getMessage() for r in caplog.records)
               assert not any(r.levelno >= logging.ERROR for r in caplog.records)
       
      +    @patch("tools.file_tools._get_file_ops")
      +    def test_rejects_read_file_line_numbered_content(self, mock_get):
      +        """#19798 — do not persist read_file's LINE_NUM|CONTENT display format."""
      +        from tools.file_tools import write_file_tool
      +
      +        content = " 1|setting: new_value\n 2|other: thing\n"
      +        result = json.loads(write_file_tool("/tmp/config.yaml", content))
      +
      +        assert "error" in result
      +        assert "line-number" in result["error"].lower()
      +        mock_get.assert_not_called()
      +
      +    @patch("tools.file_tools._get_file_ops")
      +    def test_allows_sparse_literal_pipe_content(self, mock_get):
      +        """A single literal N| line should not be treated as read_file output."""
      +        mock_ops = MagicMock()
      +        result_obj = MagicMock()
      +        result_obj.to_dict.return_value = {"status": "ok", "path": "/tmp/out.txt", "bytes": 21}
      +        mock_ops.write_file.return_value = result_obj
      +        mock_get.return_value = mock_ops
      +
      +        from tools.file_tools import write_file_tool
      +        result = json.loads(write_file_tool("/tmp/out.txt", "1|literal value\nplain line\n"))
      +
      +        assert result["status"] == "ok"
      +        mock_ops.write_file.assert_called_once()
      +
           @patch("tools.file_tools._get_file_ops")
           def test_unexpected_exception_still_logs_error(self, mock_get, caplog):
               mock_get.side_effect = RuntimeError("boom")
    • tools/file_tools.py
      @@ -657,6 +657,49 @@ def _is_internal_file_status_text(content: str) -> bool:
           return False
       
       
      +def _looks_like_read_file_line_numbered_content(content: str) -> bool:
      +    """Return True for content dominated by read_file's ``LINE_NUM|CONTENT`` display.
      +
      +    ``read_file`` intentionally returns line-numbered text to the model. If
      +    that display format is echoed into ``write_file``, config/source files are
      +    silently corrupted with prefixes like `` 1|``.  We reject writes where the
      +    non-empty lines are mostly consecutive read_file-style numbered lines, while
      +    allowing sparse literal pipe content such as a single ``1|value`` line.
      +    """
      +    if not isinstance(content, str):
      +        return False
      +
      +    lines = [line for line in content.splitlines() if line.strip()]
      +    if len(lines) < 2:
      +        return False
      +
      +    numbered: list[int] = []
      +    for line in lines:
      +        stripped = line.lstrip()
      +        prefix, sep, _rest = stripped.partition("|")
      +        if sep and prefix.isdigit():
      +            numbered.append(int(prefix))
      +
      +    if len(numbered) < 2:
      +        return False
      +    if len(numbered) / len(lines) < 0.6:
      +        return False
      +
      +    consecutive_pairs = sum(
      +        1 for prev, current in zip(numbered, numbered[1:])
      +        if current == prev + 1
      +    )
      +    return consecutive_pairs >= len(numbered) - 1
      +
      +
      +def _is_internal_file_tool_content(content: str) -> bool:
      +    """Return True when content is file-tool display text, not intended file bytes."""
      +    return (
      +        _is_internal_file_status_text(content)
      +        or _looks_like_read_file_line_numbered_content(content)
      +    )
      +
      +
       def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
           """Get or create ShellFileOperations for a terminal environment.
       
      @@ -1213,10 +1256,11 @@ def write_file_tool(path: str, content: str, task_id: str = "default",
               cross_warning = _check_cross_profile_path(path, task_id)
               if cross_warning:
                   return tool_error(cross_warning)
      -    if _is_internal_file_status_text(content):
      +    if _is_internal_file_tool_content(content):
               return tool_error(
      -            "Refusing to write internal read_file status text as file content. "
      -            "Re-read the file or reconstruct the intended file contents before writing."
      +            "Refusing to write internal read_file display text as file content. "
      +            "Strip read_file line-number prefixes or reconstruct the intended "
      +            "file contents before writing."
               )
           try:
               # Resolve once for the registry lock + stale check.  Failures here

3 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1 · superseded by another PR 1

  • #19820 fix(file): reject read_file line-numbered writeback · merged via another change → 71274f2, #50318
  • #28565 fix: reject line-numbered read_file output in write_file to prevent silent file corruption · superseded by another PR → #50318, #19820, #19798
  • #46505 fix(tools): expose raw sandbox file reads · duplicate → #46486
Closed issues · 3
  • Closed#4581read_file should have option to return raw content without line numbers
  • Closed#19798read_file returns line-numbered content that pollutes files when written back via write_file
  • Closed#46465execute_code: read_file line numbers leak into write_file content
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#50318
Closed issues
3
Attempts
3 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.