mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Follow-ups on top of the cherry-picked #62644 mechanism, porting it to current main and closing the salvage-review requirements: - proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS when it reclaims a meaningful token batch, measured on the pruned output. A committed prune rewrites already-sent history and invalidates the provider prompt-cache prefix; this hysteresis gate keeps those breaks episodic/amortized (like a compression boundary) instead of firing every tool iteration. 0 disables the gate. (Design point credited to the #62389 review cycle's prune_minimum_tokens.) - Standard no-op caller contract: every skip path returns the INPUT list object; the loop commits only on 'result is not messages' + non-zero count. - Loop call is getattr+callable guarded (plugin engines predating the hook, SimpleNamespace test doubles) and exception-swallowed at debug level. - Config parse follows the compression.max_attempts hardened semantics: booleans rejected, fractional floats rejected, integral floats/numeric strings accepted; negative trigger = disabled. - cli-config.yaml.example documented (all three keys) and gateway _CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent. - Tests: min-reclaim gate both directions, input-object no-op contract, no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule), default-off zero-behavior-change pin, config parse seam, and behavioral loop-wiring tests (consulted/commit/no-op/absent-method/raising).
277 lines
12 KiB
Python
277 lines
12 KiB
Python
"""Tests for proactive tool-result pruning.
|
||
|
||
``ContextCompressor.prune_tool_results_only`` runs the cheap, deterministic
|
||
Phase-1 prune (summarize old tool outputs, dedup repeats) on a cost-oriented
|
||
trigger that is INDEPENDENT of the full-compression threshold. On large-window
|
||
models ``should_compress()`` (~50% of the window) rarely fires, so without this
|
||
the old tool outputs ride in history and are re-sent verbatim every turn.
|
||
|
||
Mirrors the construction/patching conventions in test_context_compressor.py.
|
||
"""
|
||
|
||
from unittest.mock import patch
|
||
|
||
from agent.context_compressor import ContextCompressor, _PRUNED_TOOL_PLACEHOLDER
|
||
|
||
LARGE_WINDOW = 1_000_000
|
||
|
||
|
||
def _compressor(**kw):
|
||
defaults = dict(
|
||
model="test",
|
||
quiet_mode=True,
|
||
threshold_percent=0.50,
|
||
protect_first_n=2,
|
||
protect_last_n=4,
|
||
)
|
||
defaults.update(kw)
|
||
with patch(
|
||
"agent.context_compressor.get_model_context_length",
|
||
return_value=LARGE_WINDOW,
|
||
):
|
||
return ContextCompressor(**defaults)
|
||
|
||
|
||
def _assistant_call(cid, name="terminal", args='{"cmd":"ls"}'):
|
||
return {
|
||
"role": "assistant",
|
||
"content": "",
|
||
"tool_calls": [
|
||
{"id": cid, "type": "function",
|
||
"function": {"name": name, "arguments": args}}
|
||
],
|
||
}
|
||
|
||
|
||
def _tool_msg(cid, content):
|
||
return {"role": "tool", "tool_call_id": cid, "content": content}
|
||
|
||
|
||
def _build(n_pairs, big_indices, big_chars=9000, small="ok"):
|
||
"""system + n_pairs of (assistant tool_call, tool result).
|
||
|
||
Tool results whose pair index is in ``big_indices`` get a distinct payload
|
||
of ``big_chars`` characters; the rest get a tiny payload.
|
||
"""
|
||
msgs = [{"role": "system", "content": "sys"}]
|
||
for i in range(n_pairs):
|
||
cid = f"call_{i}"
|
||
msgs.append(_assistant_call(cid))
|
||
if i in big_indices:
|
||
msgs.append(_tool_msg(cid, chr(65 + (i % 26)) * big_chars))
|
||
else:
|
||
msgs.append(_tool_msg(cid, small))
|
||
return msgs
|
||
|
||
|
||
def _tool_by_id(msgs, cid):
|
||
return [m for m in msgs if m.get("role") == "tool" and m.get("tool_call_id") == cid][0]
|
||
|
||
|
||
def test_prunes_below_compression_threshold():
|
||
"""The whole point: prune fires at 120k tokens, far below the ~500k
|
||
(50% of 1M) full-compression trigger that would otherwise never run."""
|
||
c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000)
|
||
assert c.should_compress(prompt_tokens=120_000) is False # compression would NOT run
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert pruned >= 3
|
||
assert len(result) == len(msgs)
|
||
for cid in ("call_0", "call_1", "call_2"):
|
||
m = _tool_by_id(result, cid)
|
||
assert len(m["content"]) < 9000 # summarized
|
||
assert m["content"] != _PRUNED_TOOL_PLACEHOLDER # informative, not a blank placeholder
|
||
|
||
|
||
def test_disabled_by_default_is_noop():
|
||
c = _compressor() # proactive_prune_tokens defaults to 0
|
||
assert c.proactive_prune_tokens == 0
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=500_000)
|
||
assert pruned == 0
|
||
assert [m.get("content") for m in result] == [m.get("content") for m in msgs]
|
||
|
||
|
||
def test_below_trigger_is_noop():
|
||
c = _compressor(proactive_prune_tokens=48_000)
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000)
|
||
assert pruned == 0
|
||
|
||
|
||
def test_recent_tail_is_protected():
|
||
c = _compressor(
|
||
proactive_prune_tokens=48_000,
|
||
proactive_prune_min_result_chars=8_000,
|
||
proactive_prune_min_reclaim_tokens=0, # gate off: this test pins tail semantics
|
||
)
|
||
# pair 0 tool is old (index 2); pair 7 tool is in the last-4 protected tail (index 16)
|
||
msgs = _build(8, big_indices={0, 7})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert len(_tool_by_id(result, "call_7")["content"]) == 9000 # protected, untouched
|
||
assert len(_tool_by_id(result, "call_0")["content"]) < 9000 # old, summarized
|
||
|
||
|
||
def test_size_floor_spares_small_results():
|
||
c = _compressor(
|
||
proactive_prune_tokens=48_000,
|
||
proactive_prune_min_result_chars=8_000,
|
||
proactive_prune_min_reclaim_tokens=0, # gate off: this test pins the size floor
|
||
)
|
||
msgs = _build(8, big_indices={1}, big_chars=9000)
|
||
for m in msgs: # make pair 0's tool 5000 chars (< 8000 floor), still old
|
||
if m.get("tool_call_id") == "call_0":
|
||
m["content"] = "Z" * 5000
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert len(_tool_by_id(result, "call_0")["content"]) == 5000 # under floor -> untouched
|
||
assert len(_tool_by_id(result, "call_1")["content"]) < 9000 # over floor -> summarized
|
||
|
||
|
||
def test_structure_preserved():
|
||
c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000)
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
roles_before = [m["role"] for m in msgs]
|
||
ids_before = [m.get("tool_call_id") for m in msgs]
|
||
result, _ = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert len(result) == len(msgs)
|
||
assert [m["role"] for m in result] == roles_before
|
||
assert [m.get("tool_call_id") for m in result] == ids_before
|
||
|
||
|
||
def test_idempotent():
|
||
c = _compressor(proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000)
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
first, n1 = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert n1 >= 3
|
||
second, n2 = c.prune_tool_results_only(first, current_tokens=120_000)
|
||
assert n2 == 0
|
||
assert [m.get("content") for m in second] == [m.get("content") for m in first]
|
||
|
||
|
||
def test_prune_old_tool_results_default_floor_unchanged():
|
||
"""Backward-compat: without min_prune_chars, _prune_old_tool_results still
|
||
prunes >200-char results (the compression Phase-1 caller's behavior)."""
|
||
c = _compressor()
|
||
msgs = _build(8, big_indices=set())
|
||
for m in msgs: # a 300-char old tool result
|
||
if m.get("tool_call_id") == "call_0":
|
||
m["content"] = "Q" * 300
|
||
result, pruned = c._prune_old_tool_results(msgs, protect_tail_count=4)
|
||
assert len(_tool_by_id(result, "call_0")["content"]) < 300
|
||
assert pruned >= 1
|
||
|
||
|
||
def test_min_result_chars_floor_is_clamped():
|
||
"""Config-robustness: a floor below 200 (or negative) is clamped up to 200,
|
||
while a configured 0 falls back to the 8000 default via ``or``. Without the
|
||
clamp, a tiny floor lets Pass 2 re-summarize its own (short) summary every
|
||
turn, and a negative floor strips every non-tail tool result."""
|
||
assert _compressor(proactive_prune_min_result_chars=0).proactive_prune_min_result_chars == 8000
|
||
assert _compressor(proactive_prune_min_result_chars=50).proactive_prune_min_result_chars == 200
|
||
assert _compressor(proactive_prune_min_result_chars=-1).proactive_prune_min_result_chars == 200
|
||
assert _compressor(proactive_prune_min_result_chars=8000).proactive_prune_min_result_chars == 8000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Salvage follow-ups: no-op caller contract, prompt-cache hysteresis gate,
|
||
# no-orphan pairing invariant, and the default-off behavior pin.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_noop_paths_return_input_object():
|
||
"""Standard caller contract: every no-op path hands back the INPUT list
|
||
object so callers can gate bookkeeping on ``result is not input``."""
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
# Disabled (default)
|
||
c = _compressor()
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=500_000)
|
||
assert pruned == 0 and result is msgs
|
||
# Below trigger
|
||
c = _compressor(proactive_prune_tokens=48_000)
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000)
|
||
assert pruned == 0 and result is msgs
|
||
# Above trigger but nothing prunable (all results tiny)
|
||
c = _compressor(proactive_prune_tokens=48_000)
|
||
tiny = _build(8, big_indices=set())
|
||
result, pruned = c.prune_tool_results_only(tiny, current_tokens=120_000)
|
||
assert pruned == 0 and result is tiny
|
||
|
||
|
||
def test_min_reclaim_gate_blocks_small_prunes():
|
||
"""Prompt-cache hysteresis: a prune that would reclaim less than
|
||
``proactive_prune_min_reclaim_tokens`` must NOT commit (returns the input
|
||
object) — rewriting already-sent history for a trivial saving would break
|
||
the provider's cached prefix every tool iteration."""
|
||
c = _compressor(
|
||
proactive_prune_tokens=48_000,
|
||
proactive_prune_min_result_chars=8_000,
|
||
proactive_prune_min_reclaim_tokens=1_000_000, # unreachably high
|
||
)
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert pruned == 0
|
||
assert result is msgs # input object — caller commits nothing
|
||
|
||
|
||
def test_min_reclaim_gate_allows_large_prunes():
|
||
"""A prune reclaiming more than the gate commits normally."""
|
||
c = _compressor(
|
||
proactive_prune_tokens=48_000,
|
||
proactive_prune_min_result_chars=8_000,
|
||
proactive_prune_min_reclaim_tokens=1_000, # 3×9000 chars ≈ 6.7K tokens reclaimed
|
||
)
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert pruned >= 3
|
||
assert result is not msgs
|
||
|
||
|
||
def test_min_reclaim_gate_default_and_clamp():
|
||
"""Default 4096; negative/None coerce to disabled (0)."""
|
||
assert _compressor().proactive_prune_min_reclaim_tokens == 4096
|
||
assert _compressor(proactive_prune_min_reclaim_tokens=0).proactive_prune_min_reclaim_tokens == 0
|
||
assert _compressor(proactive_prune_min_reclaim_tokens=-5).proactive_prune_min_reclaim_tokens == 0
|
||
assert _compressor(proactive_prune_min_reclaim_tokens=None).proactive_prune_min_reclaim_tokens == 0
|
||
|
||
|
||
def test_no_orphans_both_directions():
|
||
"""tool_call_id pairing survives the prune in BOTH directions: every
|
||
surviving tool result has its assistant call, and every assistant tool_call
|
||
has its result row (the #69830 test-pin rule — never assert exact surviving
|
||
pair counts, only the pairing invariant)."""
|
||
c = _compressor(
|
||
proactive_prune_tokens=48_000,
|
||
proactive_prune_min_result_chars=8_000,
|
||
proactive_prune_min_reclaim_tokens=0,
|
||
)
|
||
msgs = _build(10, big_indices={0, 1, 2, 3, 4})
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=120_000)
|
||
assert pruned >= 1
|
||
call_ids = set()
|
||
for m in result:
|
||
if m.get("role") == "assistant":
|
||
for tc in m.get("tool_calls") or []:
|
||
call_ids.add(tc["id"] if isinstance(tc, dict) else tc.id)
|
||
result_ids = {m["tool_call_id"] for m in result if m.get("role") == "tool"}
|
||
assert result_ids <= call_ids, "orphan tool results without a matching call"
|
||
assert call_ids <= result_ids, "orphan tool calls without a matching result"
|
||
|
||
|
||
def test_unset_config_zero_behavior_change():
|
||
"""Pin: with the config knobs unset, the compressor behaves byte-identically
|
||
to pre-feature main — the prune path is dead code and the full-compression
|
||
Phase-1 caller keeps its 200-char floor."""
|
||
c = _compressor() # nothing configured
|
||
assert c.proactive_prune_tokens == 0
|
||
msgs = _build(8, big_indices={0, 1, 2})
|
||
import copy
|
||
snapshot = copy.deepcopy(msgs)
|
||
result, pruned = c.prune_tool_results_only(msgs, current_tokens=10_000_000)
|
||
assert pruned == 0
|
||
assert result is msgs
|
||
assert msgs == snapshot # input never mutated
|
||
# And the compression-path caller still prunes at the 200-char default floor
|
||
# (min_prune_chars default unchanged).
|
||
import inspect
|
||
sig = inspect.signature(c._prune_old_tool_results)
|
||
assert sig.parameters["min_prune_chars"].default == 200
|