From f2b1b3f1a3ff28ebc39b46cdc02e5febe872281b Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 23 Apr 2026 17:05:10 -0700 Subject: [PATCH] feat(config): make tool output truncation limits configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#23770: expose a new `tool_output` config section so users can tune the hardcoded truncation caps that apply to terminal output and read_file pagination. Three knobs under `tool_output`: - max_bytes (default 50_000) — terminal stdout/stderr cap - max_lines (default 2000) — read_file pagination cap - max_line_length (default 2000) — per-line cap in line-numbered view All three keep their existing hardcoded values as defaults, so behaviour is unchanged when the section is absent. Power users on big-context models can raise them; small-context local models can lower them. Implementation: - New `tools/tool_output_limits.py` reads the section with defensive fallback (missing/invalid values → defaults, never raises). - `tools/terminal_tool.py` MAX_OUTPUT_CHARS now comes from get_max_bytes(). - `tools/file_operations.py` normalize_read_pagination() and _add_line_numbers() now pull the limits at call time. - `hermes_cli/config.py` DEFAULT_CONFIG gains the `tool_output` section so `hermes setup` writes defaults into fresh configs. - Docs page `user-guide/configuration.md` gains a "Tool Output Truncation Limits" section with large-context and small-context example configs. Tests (18 new in tests/tools/test_tool_output_limits.py): - Default resolution with missing / malformed / non-dict config. - Full and partial user overrides. - Coercion of bad values (None, negative, wrong type, str int). - Shortcut accessors delegate correctly. - DEFAULT_CONFIG exposes the section with the right defaults. - Integration: normalize_read_pagination clamps to the configured max_lines. --- hermes_cli/config.py | 22 +++- tests/tools/test_tool_output_limits.py | 152 +++++++++++++++++++++++ tools/file_operations.py | 13 +- tools/terminal_tool.py | 3 +- tools/tool_output_limits.py | 92 ++++++++++++++ website/docs/user-guide/configuration.md | 29 +++++ 6 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 tests/tools/test_tool_output_limits.py create mode 100644 tools/tool_output_limits.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c578ded96..282327c84 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -486,7 +486,27 @@ DEFAULT_CONFIG = { # 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 diff --git a/tests/tools/test_tool_output_limits.py b/tests/tools/test_tool_output_limits.py new file mode 100644 index 000000000..19fa3fc05 --- /dev/null +++ b/tests/tools/test_tool_output_limits.py @@ -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 diff --git a/tools/file_operations.py b/tools/file_operations.py index 7e75578b2..9e0b44c14 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -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 @@ -414,12 +419,14 @@ class ShellFileOperations(FileOperations): 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) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 22c8dcbc6..b288d4ad9 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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) diff --git a/tools/tool_output_limits.py b/tools/tool_output_limits.py new file mode 100644 index 000000000..fd24a2da3 --- /dev/null +++ b/tools/tool_output_limits.py @@ -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"] diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 055fdb3d3..420ca1468 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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: