fix(agent): defer turns during compression lock contention - #49874
fix(agent): defer turns during compression lock contention#49874helix4u wants to merge 1 commit into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the ambiguous lock-loser no-op and carrying a distinct outcome through the turn result.
Problems
- Current main added a separate pre-API compression path after this branch:
90f84144ecalls_compress_context()atagent/conversation_loop.py:1034and unconditionallycontinues at line 1063. It is not covered by this PR's diff, so a lock-loser there would still continue toward a provider request. - The new preflight unit test is useful, but it does not cover that current-main pre-API path or prove that a deferred lock result prevents the provider call.
Suggested changes
- During salvage, apply the same soft-deferral check immediately after
agent/conversation_loop.py:1034. - Add provider-mock regressions for both preflight and pre-API lock contention; assert no request is dispatched and the result is
compression_deferred, notcompression_exhausted.
Automated hermes-sweeper review.
| @@ -560,6 +590,14 @@ def run_conversation( | |||
| _plugin_user_context = _ctx.plugin_user_context | |||
There was a problem hiding this comment.
Please carry this same deferral check to the current-main pre-API compaction path introduced by 90f84144e: it calls _compress_context() at agent/conversation_loop.py:1034 and otherwise continues toward request construction. Add a regression that pre-holds the lock and asserts no provider request is dispatched.
… exhausting A lock-loser compression pass returns its input unchanged, which the automatic compression sites misread as 'cannot compress further': the preflight loop armed the insufficient-progress blocker, the pre-API gate burned a shared attempt, and a lock-contended 413/overflow retried into the attempt cap and returned compression_exhausted — which the gateway answers with a full session auto-reset (#9893/#35809). A temporary concurrent-compression defer wiped the session. Consume the landed #69870 lock-skip signal on every automatic path (preflight in turn_context, pre-API pressure gate, 413 handler, overflow handler, post-tool compaction): when a pass no-ops AND the type-pinned lock-skip flag is set, refund the attempt (never count it toward the cap or the insufficient-progress blocker), and when the turn cannot proceed (provider already proved the request does not fit) end it with a soft compression_deferred result — distinct from compression_exhausted — so the gateway keeps the session intact and the next message retries after the concurrent compressor finishes. The new compression_skipped_due_to_lock() reader is type-pinned (is True or isinstance(str)) per the MagicMock auto-attribute rule, and compress_context() now also clears the signal at the very top of every attempt (per-attempt state rule, #58629/#69853) so a stale value can never make a later breaker/codex no-op look like lock contention. Salvaged from PR #49874; rebuilt on main's #69870 _compression_skipped_due_to_lock signal instead of the PR's parallel _compression_deferred_by_lock triple.
… regression suite Gateway half of the #49874 salvage: pass compression_deferred through both _run_agent_inner result dicts and guard the compression-exhausted auto-reset block with it — a lock-contended defer keeps the session intact (the concurrent compressor is actively shrinking it) instead of wiping it via reset_session. Regression tests: - tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and 400-overflow turns whose compression pass lost the lock end as compression_deferred (failed=False, no compression_exhausted); flag unset keeps the terminal exhaustion path byte-identical; type-pin tests vs MagicMock agents and junk flag values; cap=1 e2e proving the refunded pre-API defer leaves the budget for the provider-proven 413 retry. - tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight pass stops the loop WITHOUT arming preflight_compression_blocked; plain no-op still arms it; MagicMock junk does not defer. - tests/gateway/test_compression_deferred_soft_result.py — AST pin that the deferred branch guards the auto-reset chain and performs no session mutation (mirrors test_35809_auto_reset_clean_context.py).
… exhausting A lock-loser compression pass returns its input unchanged, which the automatic compression sites misread as 'cannot compress further': the preflight loop armed the insufficient-progress blocker, the pre-API gate burned a shared attempt, and a lock-contended 413/overflow retried into the attempt cap and returned compression_exhausted — which the gateway answers with a full session auto-reset (#9893/#35809). A temporary concurrent-compression defer wiped the session. Consume the landed #69870 lock-skip signal on every automatic path (preflight in turn_context, pre-API pressure gate, 413 handler, overflow handler, post-tool compaction): when a pass no-ops AND the type-pinned lock-skip flag is set, refund the attempt (never count it toward the cap or the insufficient-progress blocker), and when the turn cannot proceed (provider already proved the request does not fit) end it with a soft compression_deferred result — distinct from compression_exhausted — so the gateway keeps the session intact and the next message retries after the concurrent compressor finishes. The new compression_skipped_due_to_lock() reader is type-pinned (is True or isinstance(str)) per the MagicMock auto-attribute rule, and compress_context() now also clears the signal at the very top of every attempt (per-attempt state rule, #58629/#69853) so a stale value can never make a later breaker/codex no-op look like lock contention. Salvaged from PR #49874; rebuilt on main's #69870 _compression_skipped_due_to_lock signal instead of the PR's parallel _compression_deferred_by_lock triple.
… regression suite Gateway half of the #49874 salvage: pass compression_deferred through both _run_agent_inner result dicts and guard the compression-exhausted auto-reset block with it — a lock-contended defer keeps the session intact (the concurrent compressor is actively shrinking it) instead of wiping it via reset_session. Regression tests: - tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and 400-overflow turns whose compression pass lost the lock end as compression_deferred (failed=False, no compression_exhausted); flag unset keeps the terminal exhaustion path byte-identical; type-pin tests vs MagicMock agents and junk flag values; cap=1 e2e proving the refunded pre-API defer leaves the budget for the provider-proven 413 retry. - tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight pass stops the loop WITHOUT arming preflight_compression_blocked; plain no-op still arms it; MagicMock junk does not defer. - tests/gateway/test_compression_deferred_soft_result.py — AST pin that the deferred branch guards the auto-reset chain and performs no session mutation (mirrors test_35809_auto_reset_clean_context.py).
|
Merged via salvage PR #70285 — thanks @helix4u for identifying the exhaustion-misclassification bug: a lock-contended compression skip could burn the attempt cap on 413 and return |
… exhausting A lock-loser compression pass returns its input unchanged, which the automatic compression sites misread as 'cannot compress further': the preflight loop armed the insufficient-progress blocker, the pre-API gate burned a shared attempt, and a lock-contended 413/overflow retried into the attempt cap and returned compression_exhausted — which the gateway answers with a full session auto-reset (NousResearch#9893/NousResearch#35809). A temporary concurrent-compression defer wiped the session. Consume the landed NousResearch#69870 lock-skip signal on every automatic path (preflight in turn_context, pre-API pressure gate, 413 handler, overflow handler, post-tool compaction): when a pass no-ops AND the type-pinned lock-skip flag is set, refund the attempt (never count it toward the cap or the insufficient-progress blocker), and when the turn cannot proceed (provider already proved the request does not fit) end it with a soft compression_deferred result — distinct from compression_exhausted — so the gateway keeps the session intact and the next message retries after the concurrent compressor finishes. The new compression_skipped_due_to_lock() reader is type-pinned (is True or isinstance(str)) per the MagicMock auto-attribute rule, and compress_context() now also clears the signal at the very top of every attempt (per-attempt state rule, NousResearch#58629/NousResearch#69853) so a stale value can never make a later breaker/codex no-op look like lock contention. Salvaged from PR NousResearch#49874; rebuilt on main's NousResearch#69870 _compression_skipped_due_to_lock signal instead of the PR's parallel _compression_deferred_by_lock triple.
… regression suite Gateway half of the NousResearch#49874 salvage: pass compression_deferred through both _run_agent_inner result dicts and guard the compression-exhausted auto-reset block with it — a lock-contended defer keeps the session intact (the concurrent compressor is actively shrinking it) instead of wiping it via reset_session. Regression tests: - tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and 400-overflow turns whose compression pass lost the lock end as compression_deferred (failed=False, no compression_exhausted); flag unset keeps the terminal exhaustion path byte-identical; type-pin tests vs MagicMock agents and junk flag values; cap=1 e2e proving the refunded pre-API defer leaves the budget for the provider-proven 413 retry. - tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight pass stops the loop WITHOUT arming preflight_compression_blocked; plain no-op still arms it; MagicMock junk does not defer. - tests/gateway/test_compression_deferred_soft_result.py — AST pin that the deferred branch guards the auto-reset chain and performs no session mutation (mirrors test_35809_auto_reset_clean_context.py).
What does this PR do?
Prevents a second same-session turn from continuing into a provider request while another path is already compressing that session.
Today,
compress_context()returns the original messages unchanged when it loses the per-session compression lock. That prevents a session fork, but callers only seelen(returned) == len(input), which is also the signal for genuine compression no-progress. In preflight or overflow recovery, the caller can then continue with the unchanged oversized transcript and hit the provider anyway.This PR marks compression-lock contention explicitly on the agent and has preflight/overflow callers return a soft
compression_deferredresult instead of treating the unchanged messages as exhausted compression.Related Issue
N/A
Type of Change
Changes Made
agent/conversation_compression.py_compression_deferred_by_lock/ session / holder state when the compression lock is already held.agent/turn_context.pyTurnContextso the turn can stop before any provider request is built.agent/conversation_loop.pycompression_deferredresult from preflight, 413 payload-too-large recovery, context-overflow recovery, and post-tool compression when lock contention is detected.compression_exhausted.tests/agent/test_compression_concurrent_fork.pytests/agent/test_turn_context.pyHow to Test
python3 -m py_compile agent/conversation_compression.py agent/turn_context.py agent/conversation_loop.py tests/agent/test_compression_concurrent_fork.py tests/agent/test_turn_context.py tests/gateway/test_compression_concurrent_sessions.pyscripts/run_tests.sh -j 4 tests/agent/test_compression_concurrent_fork.py tests/agent/test_turn_context.py tests/gateway/test_compression_concurrent_sessions.py/home/gille/.hermes/hermes-agent/venv/bin/python -m ruff check agent/conversation_compression.py agent/turn_context.py agent/conversation_loop.py tests/agent/test_compression_concurrent_fork.py tests/agent/test_turn_context.py tests/gateway/test_compression_concurrent_sessions.pygit diff --check -- agent/conversation_compression.py agent/turn_context.py agent/conversation_loop.py tests/agent/test_compression_concurrent_fork.py tests/agent/test_turn_context.py tests/gateway/test_compression_concurrent_sessions.pyChecklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AFor New Skills
N/A
Screenshots / Logs
Targeted test output: