Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,27 @@ def _ensure_hermes_home_managed(home: Path):
# exceed this are rejected with guidance to use offset+limit.
# 100K chars ≈ 25–35K tokens across typical tokenisers.
"file_read_max_chars": 100_000,


# Tool-output truncation thresholds. When terminal output or a
# single read_file page exceeds these limits, Hermes truncates the
# payload sent to the model (keeping head + tail for terminal,
# enforcing pagination for read_file). Tuning these trades context
# footprint against how much raw output the model can see in one
# shot. Ported from anomalyco/opencode PR #23770.
#
# - max_bytes: terminal_tool output cap, in chars
# (default 50_000 ≈ 12-15K tokens).
# - max_lines: read_file pagination cap — the maximum `limit`
# a single read_file call can request before
# being clamped (default 2000).
# - max_line_length: per-line cap applied when read_file emits a
# line-numbered view (default 2000 chars).
"tool_output": {
"max_bytes": 50_000,
"max_lines": 2000,
"max_line_length": 2000,
},

"compression": {
"enabled": True,
"threshold": 0.50, # compress when context usage exceeds this ratio
Expand Down
152 changes: 152 additions & 0 deletions tests/tools/test_tool_output_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Tests for tools.tool_output_limits.

Covers:
1. Default values when no config is provided.
2. Config override picks up user-supplied max_bytes / max_lines /
max_line_length.
3. Malformed values (None, negative, wrong type) fall back to defaults
rather than raising.
4. Integration: the helpers return what the terminal_tool and
file_operations call paths will actually consume.

Port-tracking: anomalyco/opencode PR #23770
(feat(truncate): allow configuring tool output truncation limits).
"""

from __future__ import annotations

from unittest.mock import patch

import pytest

from tools import tool_output_limits as tol


class TestDefaults:
def test_defaults_match_previous_hardcoded_values(self):
assert tol.DEFAULT_MAX_BYTES == 50_000
assert tol.DEFAULT_MAX_LINES == 2000
assert tol.DEFAULT_MAX_LINE_LENGTH == 2000

def test_get_limits_returns_defaults_when_config_missing(self):
with patch("hermes_cli.config.load_config", return_value={}):
limits = tol.get_tool_output_limits()
assert limits == {
"max_bytes": tol.DEFAULT_MAX_BYTES,
"max_lines": tol.DEFAULT_MAX_LINES,
"max_line_length": tol.DEFAULT_MAX_LINE_LENGTH,
}

def test_get_limits_returns_defaults_when_config_not_a_dict(self):
# load_config should always return a dict but be defensive anyway.
with patch("hermes_cli.config.load_config", return_value="not a dict"):
limits = tol.get_tool_output_limits()
assert limits["max_bytes"] == tol.DEFAULT_MAX_BYTES

def test_get_limits_returns_defaults_when_load_config_raises(self):
def _boom():
raise RuntimeError("boom")

with patch("hermes_cli.config.load_config", side_effect=_boom):
limits = tol.get_tool_output_limits()
assert limits["max_lines"] == tol.DEFAULT_MAX_LINES


class TestOverrides:
def test_user_config_overrides_all_three(self):
cfg = {
"tool_output": {
"max_bytes": 100_000,
"max_lines": 5000,
"max_line_length": 4096,
}
}
with patch("hermes_cli.config.load_config", return_value=cfg):
limits = tol.get_tool_output_limits()
assert limits == {
"max_bytes": 100_000,
"max_lines": 5000,
"max_line_length": 4096,
}

def test_partial_override_preserves_other_defaults(self):
cfg = {"tool_output": {"max_bytes": 200_000}}
with patch("hermes_cli.config.load_config", return_value=cfg):
limits = tol.get_tool_output_limits()
assert limits["max_bytes"] == 200_000
assert limits["max_lines"] == tol.DEFAULT_MAX_LINES
assert limits["max_line_length"] == tol.DEFAULT_MAX_LINE_LENGTH

def test_section_not_a_dict_falls_back(self):
cfg = {"tool_output": "nonsense"}
with patch("hermes_cli.config.load_config", return_value=cfg):
limits = tol.get_tool_output_limits()
assert limits["max_bytes"] == tol.DEFAULT_MAX_BYTES


class TestCoercion:
@pytest.mark.parametrize("bad", [None, "not a number", -1, 0, [], {}])
def test_invalid_values_fall_back_to_defaults(self, bad):
cfg = {"tool_output": {"max_bytes": bad, "max_lines": bad, "max_line_length": bad}}
with patch("hermes_cli.config.load_config", return_value=cfg):
limits = tol.get_tool_output_limits()
assert limits["max_bytes"] == tol.DEFAULT_MAX_BYTES
assert limits["max_lines"] == tol.DEFAULT_MAX_LINES
assert limits["max_line_length"] == tol.DEFAULT_MAX_LINE_LENGTH

def test_string_integer_is_coerced(self):
cfg = {"tool_output": {"max_bytes": "75000"}}
with patch("hermes_cli.config.load_config", return_value=cfg):
limits = tol.get_tool_output_limits()
assert limits["max_bytes"] == 75_000


class TestShortcuts:
def test_individual_accessors_delegate_to_get_tool_output_limits(self):
cfg = {
"tool_output": {
"max_bytes": 111,
"max_lines": 222,
"max_line_length": 333,
}
}
with patch("hermes_cli.config.load_config", return_value=cfg):
assert tol.get_max_bytes() == 111
assert tol.get_max_lines() == 222
assert tol.get_max_line_length() == 333


class TestDefaultConfigHasSection:
"""The DEFAULT_CONFIG in hermes_cli.config must expose tool_output so
that ``hermes setup`` and default installs stay in sync with the
helpers here."""

def test_default_config_contains_tool_output_section(self):
from hermes_cli.config import DEFAULT_CONFIG
assert "tool_output" in DEFAULT_CONFIG
section = DEFAULT_CONFIG["tool_output"]
assert isinstance(section, dict)
assert section["max_bytes"] == tol.DEFAULT_MAX_BYTES
assert section["max_lines"] == tol.DEFAULT_MAX_LINES
assert section["max_line_length"] == tol.DEFAULT_MAX_LINE_LENGTH


class TestIntegrationReadPagination:
"""normalize_read_pagination uses get_max_lines() — verify the plumbing."""

def test_pagination_limit_clamped_by_config_value(self):
from tools.file_operations import normalize_read_pagination
cfg = {"tool_output": {"max_lines": 50}}
with patch("hermes_cli.config.load_config", return_value=cfg):
offset, limit = normalize_read_pagination(offset=1, limit=1000)
# limit should have been clamped to 50 (the configured max_lines)
assert limit == 50
assert offset == 1

def test_pagination_default_when_config_missing(self):
from tools.file_operations import normalize_read_pagination
with patch("hermes_cli.config.load_config", return_value={}):
offset, limit = normalize_read_pagination(offset=10, limit=100000)
# Clamped to default MAX_LINES (2000).
assert limit == tol.DEFAULT_MAX_LINES
assert offset == 10
13 changes: 10 additions & 3 deletions tools/file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,15 @@ def normalize_read_pagination(offset: Any = DEFAULT_READ_OFFSET,
Tool schemas declare minimum/maximum values, but not every caller or
provider enforces schemas before dispatch. Clamp here so invalid values
cannot leak into sed ranges like ``0,-1p``.

The upper bound on ``limit`` comes from ``tool_output.max_lines`` in
config.yaml (defaults to the module-level ``MAX_LINES`` constant).
"""
from tools.tool_output_limits import get_max_lines
max_lines = get_max_lines()
normalized_offset = max(1, _coerce_int(offset, DEFAULT_READ_OFFSET))
normalized_limit = _coerce_int(limit, DEFAULT_READ_LIMIT)
normalized_limit = max(1, min(normalized_limit, MAX_LINES))
normalized_limit = max(1, min(normalized_limit, max_lines))
return normalized_offset, normalized_limit


Expand Down Expand Up @@ -414,12 +419,14 @@ def _is_image(self, path: str) -> bool:

def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
"""Add line numbers to content in LINE_NUM|CONTENT format."""
from tools.tool_output_limits import get_max_line_length
max_line_length = get_max_line_length()
lines = content.split('\n')
numbered = []
for i, line in enumerate(lines, start=start_line):
# Truncate long lines
if len(line) > MAX_LINE_LENGTH:
line = line[:MAX_LINE_LENGTH] + "... [truncated]"
if len(line) > max_line_length:
line = line[:max_line_length] + "... [truncated]"
numbered.append(f"{i:6d}|{line}")
return '\n'.join(numbered)

Expand Down
3 changes: 2 additions & 1 deletion tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1805,7 +1805,8 @@ def terminal_tool(
pass

# Truncate output if too long, keeping both head and tail
MAX_OUTPUT_CHARS = 50000
from tools.tool_output_limits import get_max_bytes
MAX_OUTPUT_CHARS = get_max_bytes()
if len(output) > MAX_OUTPUT_CHARS:
head_chars = int(MAX_OUTPUT_CHARS * 0.4) # 40% head (error messages often appear early)
tail_chars = MAX_OUTPUT_CHARS - head_chars # 60% tail (most recent/relevant output)
Expand Down
92 changes: 92 additions & 0 deletions tools/tool_output_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Configurable tool-output truncation limits.

Ported from anomalyco/opencode PR #23770 (``feat(truncate): allow
configuring tool output truncation limits``).

OpenCode hardcoded ``MAX_LINES = 2000`` and ``MAX_BYTES = 50 * 1024``
as tool-output truncation thresholds. Hermes-agent had the same
hardcoded constants in two places:

* ``tools/terminal_tool.py`` — ``MAX_OUTPUT_CHARS = 50000`` (terminal
stdout/stderr cap)
* ``tools/file_operations.py`` — ``MAX_LINES = 2000`` /
``MAX_LINE_LENGTH = 2000`` (read_file pagination cap + per-line cap)

This module centralises those values behind a single config section
(``tool_output`` in ``config.yaml``) so power users can tune them
without patching the source. The existing hardcoded numbers remain as
defaults, so behaviour is unchanged when the config key is absent.

Example ``config.yaml``::

tool_output:
max_bytes: 100000 # terminal output cap (chars)
max_lines: 5000 # read_file pagination + truncation cap
max_line_length: 2000 # per-line length cap before '... [truncated]'

The limits reader is defensive: any error (missing config file, invalid
value type, etc.) falls back to the built-in defaults so tools never
fail because of a malformed config.
"""

from __future__ import annotations

from typing import Any, Dict

# Hardcoded defaults — these match the pre-existing values, so adding
# this module is behaviour-preserving for users who don't set
# ``tool_output`` in config.yaml.
DEFAULT_MAX_BYTES = 50_000 # terminal_tool.MAX_OUTPUT_CHARS
DEFAULT_MAX_LINES = 2000 # file_operations.MAX_LINES
DEFAULT_MAX_LINE_LENGTH = 2000 # file_operations.MAX_LINE_LENGTH


def _coerce_positive_int(value: Any, default: int) -> int:
"""Return ``value`` as a positive int, or ``default`` on any issue."""
try:
iv = int(value)
except (TypeError, ValueError):
return default
if iv <= 0:
return default
return iv


def get_tool_output_limits() -> Dict[str, int]:
"""Return resolved tool-output limits, reading ``tool_output`` from config.

Keys: ``max_bytes``, ``max_lines``, ``max_line_length``. Missing or
invalid entries fall through to the ``DEFAULT_*`` constants. This
function NEVER raises.
"""
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
section = cfg.get("tool_output") if isinstance(cfg, dict) else None
if not isinstance(section, dict):
section = {}
except Exception:
section = {}

return {
"max_bytes": _coerce_positive_int(section.get("max_bytes"), DEFAULT_MAX_BYTES),
"max_lines": _coerce_positive_int(section.get("max_lines"), DEFAULT_MAX_LINES),
"max_line_length": _coerce_positive_int(
section.get("max_line_length"), DEFAULT_MAX_LINE_LENGTH
),
}


def get_max_bytes() -> int:
"""Shortcut for terminal-tool callers that only need the byte cap."""
return get_tool_output_limits()["max_bytes"]


def get_max_lines() -> int:
"""Shortcut for file-ops callers that only need the line cap."""
return get_tool_output_limits()["max_lines"]


def get_max_line_length() -> int:
"""Shortcut for file-ops callers that only need the per-line cap."""
return get_tool_output_limits()["max_line_length"]
29 changes: 29 additions & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,35 @@ file_read_max_chars: 30000

The agent also deduplicates file reads automatically — if the same file region is read twice and the file hasn't changed, a lightweight stub is returned instead of re-sending the content. This resets on context compression so the agent can re-read files after their content is summarized away.

## Tool Output Truncation Limits

Three related caps control how much raw output a tool can return before Hermes truncates it:

```yaml
tool_output:
max_bytes: 50000 # terminal output cap (chars)
max_lines: 2000 # read_file pagination cap
max_line_length: 2000 # per-line cap in read_file's line-numbered view
```

- **`max_bytes`** — When a `terminal` command produces more than this many characters of combined stdout/stderr, Hermes keeps the first 40% and last 60% and inserts a `[OUTPUT TRUNCATED]` notice between them. Default `50000` (≈12-15K tokens across typical tokenisers).
- **`max_lines`** — Upper bound on the `limit` parameter of a single `read_file` call. Requests above this are clamped so a single read can't flood the context window. Default `2000`.
- **`max_line_length`** — Per-line cap applied when `read_file` emits the line-numbered view. Lines longer than this are truncated to this many chars followed by `... [truncated]`. Default `2000`.

Raise the limits on models with large context windows that can afford more raw output per call. Lower them for small-context models to keep tool results compact:

```yaml
# Large context model (200K+)
tool_output:
max_bytes: 150000
max_lines: 5000

# Small local model (16K context)
tool_output:
max_bytes: 20000
max_lines: 500
```

## Git Worktree Isolation

Enable isolated git worktrees for running multiple agents in parallel on the same repo:
Expand Down
Loading