{"repo":"NousResearch/hermes-agent","public_id":"39548","display_label":"Fix: token reduction ignored as compression progress","summary":"Multiple fixes and bug reports addressing a flaw where compression was considered unsuccessful if message count stayed the same, even when token count decreased. The criterion for compression progress was updated to include token reduction, not just row count.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50726},"resolution":{"state":"resolved","by":"pr-50726,pr-50740"},"freshness":{"state":"verified","last_verified_at":"2026-06-22T10:28:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.26},"fix_edges":[{"issue_number":39548,"pr_number":50726,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39548,"pr_number":50740,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":39550,"pr_number":50726,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":39550,"pr_number":50740,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":39548,"title":"[Bug]: Compression token savings ignored when message count is unchanged, causing false context exhaustion","url":null,"state":"closed"},{"kind":"issue","number":39550,"title":"[Bug]: Compression token savings ignored when message count is unchanged, causing false context exhaustion","url":null,"state":"closed"},{"kind":"pr","number":39673,"title":"fix(agent): improve context compression success criterion to include token reduction","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"33efff0d8","number":0},{"kind":"pr","value":"#50726","number":50726},{"kind":"pr","value":"#50740","number":50740},{"kind":"pr","value":"#39550","number":39550},{"kind":"pr","value":"#39548","number":39548}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39900,"title":"fix(agent): continue preflight compression when tokens decrease but message count is unchanged","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"33efff0d8","number":0},{"kind":"pr","value":"#50726","number":50726},{"kind":"pr","value":"#50740","number":50740},{"kind":"pr","value":"#39550","number":39550},{"kind":"pr","value":"#39548","number":39548}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41001,"title":"Fix #39550: Compression token savings ignored when message count unchanged","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"33efff0d8","number":0},{"kind":"pr","value":"#50726","number":50726},{"kind":"pr","value":"#50740","number":50740},{"kind":"pr","value":"#39550","number":39550},{"kind":"pr","value":"#39548","number":39548}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":46987,"title":"fix(agent): detect token reduction in 413 path even when message count is unchanged","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"47b6b4cf857ba627070f2ae22cfa4c124c900ca1","number":0},{"kind":"pr","value":"#50726","number":50726}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49579,"title":"fix #39550: detect token-only compression success when message count unchanged","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"33efff0d8","number":0},{"kind":"pr","value":"#50726","number":50726},{"kind":"pr","value":"#39574","number":39574},{"kind":"pr","value":"#39673","number":39673},{"kind":"pr","value":"#39900","number":39900}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50726,"title":"fix(agent): count tokens, not just message rows, as compression progress (#23767, #39550)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","tests/run_agent/test_413_compression.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -2983,6 +2983,7 @@ def _perform_api_call(next_api_kwargs):\n                     agent._buffer_status(f\"⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n \n                     original_len = len(messages)\n+                    original_tokens = [redacted:secret-assignment]\n                     messages, active_system_prompt = agent._compress_context(\n                         messages, system_message, approx_tokens=[redacted:secret-assignment]\n                         task_id=effective_task_id,","@@ -2992,8 +2993,18 @@ def _perform_api_call(next_api_kwargs):\n                     # messages to the new session, not skipping them.\n                     conversation_history = None\n \n-                    if len(messages) < original_len:\n-                        agent._buffer_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                    # Re-estimate tokens after compression.  Same-message-count\n+                    # compression (tool-result pruning, in-place summarization)\n+                    # can materially reduce request size without reducing the\n+                    # message array.  (#39550)\n+                    new_tokens = [redacted:secret-assignment]\n+                    approx_tokens = [redacted:secret-assignment]  # update for downstream logging\n+\n+                    if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95):\n+                        if len(messages) < original_len:\n+                            agent._buffer_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                        else:\n+                            agent._buffer_status(f\"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...\")\n                         time.sleep(2)  # Brief pause between compression retries\n                         _retry.restart_with_compressed_messages = True\n                         break","@@ -3139,6 +3150,7 @@ def _perform_api_call(next_api_kwargs):\n                     agent._buffer_status(f\"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...\")\n \n                     original_len = len(messages)\n+                    original_tokens = [redacted:secret-assignment]\n                     messages, active_system_prompt = agent._compress_context(\n                         messages, system_message, approx_tokens=[redacted:secret-assignment]\n                         task_id=effective_task_id,","@@ -3148,9 +3160,18 @@ def _perform_api_call(next_api_kwargs):\n                     # messages to the new session, not skipping them.\n                     conversation_history = None\n \n-                    if len(messages) < original_len or new_ctx and new_ctx < old_ctx:\n+                    # Re-estimate tokens after compression.  Same-message-count\n+                    # compression (tool-result pruning, in-place summarization)\n+                    # can materially reduce request size without reducing the\n+                    # message array.  (#39550)\n+                    new_tokens = [redacted:secret-assignment]\n+                    approx_tokens = [redacted:secret-assignment]  # update for downstream logging\n+\n+                    if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx):\n                         if len(messages) < original_len:\n                             agent._buffer_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                        elif new_tokens > 0 and new_tokens < original_tokens * 0.95:\n+                            agent._buffer_status(f\"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...\")\n                         time.sleep(2)  # Brief pause between compression retries\n                         _retry.restart_with_compressed_messages = True\n                         break","@@ -3159,13 +3180,13 @@ def _perform_api_call(next_api_kwargs):\n                         agent._flush_status_buffer()\n                         agent._vprint(f\"{agent.log_prefix}❌ Context length exceeded and cannot compress further.\", force=True)\n                         agent._vprint(f\"{agent.log_prefix}   💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.\", force=True)\n-                        logger.error(f\"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.\")\n+                        logger.error(f\"{agent.log_prefix}Context length exceeded: {new_tokens:,} tokens. Cannot compress further.\")\n                         agent._persist_session(messages, conversation_history)\n                         return {\n                             \"messages\": messages,\n                             \"completed\": False,\n                             \"api_calls\": api_call_count,\n-                            \"error\": f\"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.\",\n+                            \"error\": f\"Context length exceeded ({new_tokens:,} tokens). Cannot compress further.\",\n                             \"partial\": True,\n                             \"failed\": True,\n                             \"compression_exhausted\": True,"]},{"file":"tests/run_agent/test_413_compression.py","hunks":["@@ -440,6 +440,48 @@ def test_413_cannot_compress_further(self, agent):\n         assert result.get(\"partial\") is True\n         assert \"413\" in result[\"error\"]\n \n+    def test_413_retries_on_token_only_compression(self, agent):\n+        \"\"\"Same message COUNT but fewer TOKENS must count as progress and retry.\n+\n+        Regression for #39550/#23767: tool-result pruning / in-place\n+        summarization can shrink request size without dropping the message\n+        count. The old gate (len(messages) < original_len) treated that as\n+        'cannot compress further' and aborted; the fix re-estimates tokens and\n+        retries when they drop materially.\n+        \"\"\"\n+        err_413 = _make_413_error()\n+        ok_resp = _mock_response(content=\"OK after token-only compaction\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [err_413, ok_resp]\n+\n+        # 3 large messages in, 3 much smaller messages out (same count, far\n+        # fewer tokens) — exactly the token-only-progress case.\n+        prefill = [\n+            {\"role\": \"user\", \"content\": \"x\" * 4000},\n+            {\"role\": \"assistant\", \"content\": \"y\" * 4000},\n+            {\"role\": \"user\", \"content\": \"z\" * 4000},\n+        ]\n+\n+        with (\n+            patch.object(agent, \"_compress_context\") as mock_compress,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            # Same message count (3) but ~10x smaller content → token drop.\n+            mock_compress.return_value = (\n+                [\n+                    {\"role\": \"user\", \"content\": \"x\" * 300},\n+                    {\"role\": \"assistant\", \"content\": \"y\" * 300},\n+                    {\"role\": \"user\", \"content\": \"z\" * 300},\n+                ],\n+                \"compressed prompt\",\n+            )\n+            result = agent.run_conversation(\"hello\", conversation_history=prefill)\n+\n+        mock_compress.assert_called_once()\n+        assert result[\"completed\"] is True\n+        assert result[\"final_response\"] == \"OK after token-only compaction\"\n+\n \n class TestPreflightCompression:\n     \"\"\"Preflight compression should compress history before the first API call.\"\"\"\n"]}]},{"kind":"pr","number":50740,"title":"fix(agent): count tokens, not just rows, as preflight compression progress (#23767, #39548)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/turn_context.py","tests/agent/test_compression_progress.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}