mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
tools: normalize file tool pagination bounds
This commit is contained in:
parent
3e652f75b2
commit
40619b393f
5 changed files with 145 additions and 3 deletions
|
|
@ -19,6 +19,8 @@ from tools.file_operations import (
|
|||
BINARY_EXTENSIONS,
|
||||
IMAGE_EXTENSIONS,
|
||||
MAX_LINE_LENGTH,
|
||||
normalize_read_pagination,
|
||||
normalize_search_pagination,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -192,6 +194,17 @@ def file_ops(mock_env):
|
|||
|
||||
|
||||
class TestShellFileOpsHelpers:
|
||||
def test_normalize_read_pagination_clamps_invalid_values(self):
|
||||
assert normalize_read_pagination(offset=0, limit=0) == (1, 1)
|
||||
assert normalize_read_pagination(offset=-10, limit=-5) == (1, 1)
|
||||
assert normalize_read_pagination(offset="bad", limit="bad") == (1, 500)
|
||||
assert normalize_read_pagination(offset=2, limit=999999) == (2, 2000)
|
||||
|
||||
def test_normalize_search_pagination_clamps_invalid_values(self):
|
||||
assert normalize_search_pagination(offset=-10, limit=-5) == (0, 1)
|
||||
assert normalize_search_pagination(offset="bad", limit="bad") == (0, 50)
|
||||
assert normalize_search_pagination(offset=3, limit=0) == (3, 1)
|
||||
|
||||
def test_escape_shell_arg_simple(self, file_ops):
|
||||
assert file_ops._escape_shell_arg("hello") == "'hello'"
|
||||
|
||||
|
|
|
|||
|
|
@ -146,3 +146,61 @@ class TestCheckLintBracePaths:
|
|||
|
||||
assert result.success is False
|
||||
assert "SyntaxError" in result.output
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pagination bounds
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestPaginationBounds:
|
||||
"""Invalid pagination inputs should not leak into shell commands."""
|
||||
|
||||
def test_read_file_clamps_offset_and_limit_before_building_sed_range(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
commands = []
|
||||
|
||||
def fake_exec(command, *args, **kwargs):
|
||||
commands.append(command)
|
||||
if command.startswith("wc -c"):
|
||||
return MagicMock(exit_code=0, stdout="12")
|
||||
if command.startswith("head -c"):
|
||||
return MagicMock(exit_code=0, stdout="line1\nline2\n")
|
||||
if command.startswith("sed -n"):
|
||||
return MagicMock(exit_code=0, stdout="line1\n")
|
||||
if command.startswith("wc -l"):
|
||||
return MagicMock(exit_code=0, stdout="2")
|
||||
return MagicMock(exit_code=0, stdout="")
|
||||
|
||||
with patch.object(ops, "_exec", side_effect=fake_exec):
|
||||
result = ops.read_file("notes.txt", offset=0, limit=0)
|
||||
|
||||
assert result.error is None
|
||||
assert " 1|line1" in result.content
|
||||
sed_commands = [cmd for cmd in commands if cmd.startswith("sed -n")]
|
||||
assert sed_commands == ["sed -n '1,1p' 'notes.txt'"]
|
||||
|
||||
def test_search_clamps_offset_and_limit_before_building_head_pipeline(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
commands = []
|
||||
|
||||
def fake_exec(command, *args, **kwargs):
|
||||
commands.append(command)
|
||||
if command.startswith("test -e"):
|
||||
return MagicMock(exit_code=0, stdout="exists")
|
||||
if command.startswith("rg --files"):
|
||||
return MagicMock(exit_code=0, stdout="a.py\n")
|
||||
return MagicMock(exit_code=0, stdout="")
|
||||
|
||||
with patch.object(ops, "_has_command", side_effect=lambda cmd: cmd == "rg"), \
|
||||
patch.object(ops, "_exec", side_effect=fake_exec):
|
||||
result = ops.search("*.py", target="files", path=".", offset=-4, limit=-2)
|
||||
|
||||
assert result.files == ["a.py"]
|
||||
rg_commands = [cmd for cmd in commands if cmd.startswith("rg --files")]
|
||||
assert rg_commands
|
||||
assert "| head -n 1" in rg_commands[0]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ class TestReadFileHandler:
|
|||
read_file_tool("/tmp/big.txt", offset=10, limit=20)
|
||||
mock_ops.read_file.assert_called_once_with("/tmp/big.txt", 10, 20)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_invalid_offset_and_limit_are_normalized_before_dispatch(self, mock_get):
|
||||
mock_ops = MagicMock()
|
||||
result_obj = MagicMock()
|
||||
result_obj.content = "line1"
|
||||
result_obj.to_dict.return_value = {"content": "line1", "total_lines": 1}
|
||||
mock_ops.read_file.return_value = result_obj
|
||||
mock_get.return_value = mock_ops
|
||||
|
||||
from tools.file_tools import read_file_tool
|
||||
read_file_tool("/tmp/big.txt", offset=0, limit=0)
|
||||
mock_ops.read_file.assert_called_once_with("/tmp/big.txt", 1, 1)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_exception_returns_error_json(self, mock_get):
|
||||
mock_get.side_effect = RuntimeError("terminal not available")
|
||||
|
|
@ -191,6 +204,21 @@ class TestSearchHandler:
|
|||
limit=10, offset=5, output_mode="count", context=2,
|
||||
)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_search_normalizes_invalid_pagination_before_dispatch(self, mock_get):
|
||||
mock_ops = MagicMock()
|
||||
result_obj = MagicMock()
|
||||
result_obj.to_dict.return_value = {"files": []}
|
||||
mock_ops.search.return_value = result_obj
|
||||
mock_get.return_value = mock_ops
|
||||
|
||||
from tools.file_tools import search_tool
|
||||
search_tool(pattern="class", target="files", path="/src", limit=-5, offset=-2)
|
||||
mock_ops.search.assert_called_once_with(
|
||||
pattern="class", path="/src", target="files", file_glob=None,
|
||||
limit=1, offset=0, output_mode="content", context=0,
|
||||
)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_search_exception_returns_error(self, mock_get):
|
||||
mock_get.side_effect = RuntimeError("no terminal")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue