Skip to content

fix(compression): make the attempt cap config-driven and enforce it at every compression site - #69315

Merged
teknium1 merged 4 commits into
mainfrom
salvage/64010-63870-attempt-caps
Jul 22, 2026
Merged

fix(compression): make the attempt cap config-driven and enforce it at every compression site#69315
teknium1 merged 4 commits into
mainfrom
salvage/64010-63870-attempt-caps

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

The per-turn compression attempt cap is now config-driven (compression.max_attempts, default 3) and enforced consistently at every compression site — including the post-tool compaction gate, which previously had no cap at all and could compact after every tool response for the lifetime of a turn.

Changes

  • agent/agent_init.py: parse + validate compression.max_attempts (floor 1, ceiling 10, default 3); parser hardened to reject booleans (bool subclasses int — true would coerce to 1) and fractional floats (rejected, not truncated), while accepting integral floats and numeric strings.
  • agent/conversation_loop.py: resolve max_compression_attempts once at turn start; the pre-API pressure gate (previously hardcoded < 3, logged attempt=%s/3), the overflow/413 retry handlers, and the newly-capped post-tool gate all consume the same counter against the same resolved cap.
  • agent/turn_context.py: turn-start preflight compaction loop was for _pass in range(3); now sized from the same resolved cap.
  • hermes_cli/config.py / cli-config.yaml.example: compression.max_attempts documented in DEFAULT_CONFIG (default 3 = behavior-neutral when unset).
  • tests/: replaced fix(compaction): cap post-tool compression attempts per turn #63870's inspect.getsource source-shape test with behavioral loop tests (post-tool compaction fires ≤ cap times per turn, shares its budget with the pre-API gate, budget resets per turn); added an e2e test proving a 4th preflight pass runs at cap=6 while the unset default still stops at 3; extended fix(agent): make the compression retry cap config-driven (compression.max_attempts) #64010's config tests with the bool/float parser semantics.

Validation

Before After
Post-tool compaction in a long tool loop uncapped — fired after every tool response compression.max_attempts per turn (shared counter)
Pre-API pressure gate hardcoded < 3 regardless of config honors resolved cap
Preflight passes at max_attempts: 6 stopped at 3 (range(3)) runs the 4th–6th passes
max_attempts: true / 4.7 coerced to 1 / truncated to 4 rejected → default 3

Targeted tests: 67 passed across the five compression-focused files; -k 'compression and (attempt or cap or retry)' sweep over tests/run_agent/ tests/agent/: 27 passed, 0 failed.

Credit

Salvages #64010 by @Kenmege and #63870 by @dombejar; unified so the pre-API gate, retry loop, preflight passes, and the previously-uncapped post-tool gate all honor compression.max_attempts.

Infographic

compression-attempt-cap

Kennedy Umege and others added 4 commits July 22, 2026 05:13
….max_attempts)

The conversation loop hardcodes max_compression_attempts = 3. Sessions
that legitimately need more rounds are stranded: on a restart history
reload, incompressible tool schemas can keep the per-request estimate
above the compressor threshold even though the message floor compresses
correctly, so three rounds cannot clear it and the turn dies with
"Context length exceeded: max compression attempts (3) reached" — the
same failure class as #62605, where the rough estimate similarly leaves
3 retries short.

Make the cap a config key, compression.max_attempts:

- default 3 = identical to today, so an unset key is behavior-neutral;
- parsed and validated in agent_init alongside the other compression.*
  keys (>= 1, hard-capped at 10, non-integer values fall back to 3),
  attached as agent.max_compression_attempts;
- the loop reads it via getattr(agent, "max_compression_attempts", 3),
  so objects without the attribute keep the prior behavior;
- documented in the DEFAULT_CONFIG compression block.

Tests pin the parse/validate/attach seam: default preserved, custom
value honored, floor and ceiling enforced, garbage tolerated, and the
loop-side getattr degradation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the salvaged #64010 (Kenmege) and #63870 (dombejar) commits,
making one resolved compression.max_attempts cap govern ALL per-turn
compression attempt sites:

- conversation_loop: resolve max_compression_attempts ONCE at turn start
  (it was previously re-resolved inside the API-call loop) and route the
  pre-API pressure gate through it — that gate still hardcoded
  'compression_attempts < 3' and logged 'attempt=%s/3'.
- conversation_loop: the salvaged post-tool compaction gate now uses the
  resolved cap instead of a hardcoded 3.
- turn_context: the preflight compaction loop was 'for _pass in range(3)';
  it now sizes itself from the same resolved cap.
- agent_init: harden the max_attempts parser — reject booleans (bool
  subclasses int; 'true' would coerce to 1), reject fractional floats
  instead of truncating them, keep accepting integral floats and numeric
  strings; anything else falls back to 3 (floor 1, ceiling 10 unchanged).
- tests: replace #63870's inspect.getsource source-shape test with
  behavioral loop tests (post-tool compaction fires <= cap times per turn,
  shares its budget with the pre-API gate, resets between turns); add an
  e2e test proving a 4th preflight pass runs at config cap=6 while the
  unset default still stops at 3; extend the #64010 config tests with the
  bool/float parser semantics.

Salvages #64010 by @Kenmege and #63870 by @dombejar.
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

running on 136deab

looks good to me!

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/compression Context compression and continuation sessions area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 22, 2026
@teknium1
teknium1 merged commit 1c2faed into main Jul 22, 2026
42 checks passed
@teknium1
teknium1 deleted the salvage/64010-63870-attempt-caps branch July 22, 2026 13:56
teknium1 added a commit that referenced this pull request Jul 23, 2026
…ght flow

Relocates the #20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, #69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.

Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
  ContextEngine.should_compress_preflight() -> False, so the default
  path performs no compression and touches no turn bookkeeping
  (pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
  mutually exclusive with the cap-bounded threshold multi-pass loop,
  so turn-start passes stay within the resolved
  compression.max_attempts budget in every case.
- No-op blocking (#64382 / 377244f): an engine pass that no-ops
  (_compress_context returns the input list object) neither sets nor
  clears preflight_compression_blocked and does not re-baseline the
  flush history — a sub-threshold maintenance no-op proves nothing
  about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
  codex-native gates run before the hook is ever consulted.

Salvaged from #20424 by @Beandon13. Fixes #20316.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ght flow

Relocates the NousResearch#20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, NousResearch#69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.

Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
  ContextEngine.should_compress_preflight() -> False, so the default
  path performs no compression and touches no turn bookkeeping
  (pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
  mutually exclusive with the cap-bounded threshold multi-pass loop,
  so turn-start passes stay within the resolved
  compression.max_attempts budget in every case.
- No-op blocking (NousResearch#64382 / 0e73a78): an engine pass that no-ops
  (_compress_context returns the input list object) neither sets nor
  clears preflight_compression_blocked and does not re-baseline the
  flush history — a sub-threshold maintenance no-op proves nothing
  about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
  codex-native gates run before the hook is ever consulted.

Salvaged from NousResearch#20424 by @Beandon13. Fixes NousResearch#20316.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants