{"repo":"NousResearch/hermes-agent","public_id":"16087","display_label":"Multimodal message token count underestimation fixes","summary":"These issues and PRs address a bug where token estimation for multimodal messages was incorrect, leading to oversized tail protection and ineffective context compression. Fixes focus on using text character sums for more accurate token counting in the compressor.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16369,"merged_at":"2026-04-27T04:48:10+00:00"},"resolution":{"state":"resolved","by":"pr-16369"},"freshness":{"state":"stale","last_verified_at":"2026-04-27T04:48:10+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":16087,"pr_number":16369,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-27T04:48:10+00:00"}],"members":[{"kind":"issue","number":16087,"title":"[Bug]: underestimates token count for multimodal messages, causing oversized tail protection and ineffective context compression","url":null,"state":"closed"},{"kind":"pr","number":16113,"title":"fix(compressor): use text char sum for multimodal token estimation in _find_tail_cut_by_tokens","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16369","number":16369}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16117,"title":"Fix/underestimates token count","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16113","number":16113},{"kind":"pr","value":"#16087","number":16087}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":16369,"title":"fix(compressor): use text char sum for multimodal token estimation in _find_tail_cut_by_tokens","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -484,7 +484,18 @@ def _prune_old_tool_results(\n             for i in range(len(result) - 1, -1, -1):\n                 msg = result[i]\n                 raw_content = msg.get(\"content\") or \"\"\n-                content_len = sum(len(p.get(\"text\", \"\")) for p in raw_content) if isinstance(raw_content, list) else len(raw_content)\n+                content_len = (\n+                    sum(\n+                        len(p.get(\"text\", \"\"))\n+                        if isinstance(p, dict)\n+                        else len(p)\n+                        if isinstance(p, str)\n+                        else len(str(p))\n+                        for p in raw_content\n+                    )\n+                    if isinstance(raw_content, list)\n+                    else len(raw_content)\n+                )\n                 msg_tokens = [redacted:secret-assignment] // _CHARS_PER_TOKEN + 10\n                 for tc in msg.get(\"tool_calls\") or []:\n                     if isinstance(tc, dict):","@@ -1082,8 +1093,20 @@ def _find_tail_cut_by_tokens(\n \n         for i in range(n - 1, head_end - 1, -1):\n             msg = messages[i]\n-            content = msg.get(\"content\") or \"\"\n-            msg_tokens = [redacted:secret-assignment] // _CHARS_PER_TOKEN + 10  # +10 for role/metadata\n+            raw_content = msg.get(\"content\") or \"\"\n+            content_len = (\n+                sum(\n+                    len(p.get(\"text\", \"\"))\n+                    if isinstance(p, dict)\n+                    else len(p)\n+                    if isinstance(p, str)\n+                    else len(str(p))\n+                    for p in raw_content\n+                )\n+                if isinstance(raw_content, list)\n+                else len(raw_content)\n+            )\n+            msg_tokens = [redacted:secret-assignment] // _CHARS_PER_TOKEN + 10  # +10 for role/metadata\n             # Include tool call arguments in estimate\n             for tc in msg.get(\"tool_calls\") or []:\n                 if isinstance(tc, dict):"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -846,6 +846,97 @@ def test_prune_without_token_budget_uses_message_count(self, budget_compressor):\n         # so it might or might not be pruned depending on boundary\n         assert isinstance(pruned, int)\n \n+    def test_multimodal_message_accumulates_text_chars_not_block_count(self, budget_compressor):\n+        \"\"\"_find_tail_cut_by_tokens must use text char count, not list length,\n+        for multimodal content. Regression guard for #16087.\n+\n+        Setup: 6 messages, budget=80 (soft_ceiling=120).  The multimodal message\n+        at index 1 has 500 chars of text → 135 tokens (correct) or 10 tokens (bug).\n+\n+        Fixed path: walk stops at the multimodal (44+135=179 > 120), cut stays at 2,\n+        tail = messages[2:] = 4 messages.\n+\n+        Bug path: walk counts only 10 tokens for the multimodal, exhausts to head_end,\n+        the head_end safeguard forces cut = n - min_tail = 3, tail = only 3 messages.\n+        \"\"\"\n+        c = budget_compressor\n+        # 500 chars → 500//4 + 10 = 135 tokens; len([text, image]) // 4 + 10 = 10 (bug)\n+        big_text = \"x\" * 500\n+        multimodal_content = [\n+            {\"type\": \"text\", \"text\": big_text},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://example.com/img.jpg\"}},\n+        ]\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"head1\"},               # 0\n+            {\"role\": \"user\", \"content\": multimodal_content},    # 1: BIG (index under test)\n+            {\"role\": \"assistant\", \"content\": \"tail1\"},           # 2\n+            {\"role\": \"user\", \"content\": \"tail2\"},                # 3\n+            {\"role\": \"assistant\", \"content\": \"tail3\"},           # 4\n+            {\"role\": \"user\", \"content\": \"tail4\"},                # 5\n+        ]\n+        c.tail_token_budget = 80  # soft_ceiling = 120\n+        head_end = 0\n+        cut = c._find_tail_cut_by_tokens(messages, head_end)\n+        # With the fix: cut=2, tail has 4 messages (soft_ceiling not exceeded by tail1-4).\n+        # With the bug: head_end safeguard fires → cut = n - min_tail = 3, only 3 in tail.\n+        assert len(messages) - cut >= 4, (\n+            f\"Expected ≥4 messages in tail (got {len(messages) - cut}, cut={cut}). \"\n+            \"The multimodal message was underestimated — len(list) used instead of text chars.\"\n+        )\n+\n+    def test_plain_string_content_unchanged(self, budget_compressor):\n+        \"\"\"Plain string content must still be estimated correctly after the fix.\"\"\"\n+        c = budget_compressor\n+        # Same layout as the multimodal test but with a plain 500-char string.\n+        # Both buggy and fixed code count plain strings the same way (len(str)).\n+        # With 135 tokens the plain string also exceeds soft_ceiling=120, so\n+        # the walk stops at index 1 and tail has 4 messages — same as the fix path.\n+        big_plain = \"x\" * 500\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"head1\"},\n+            {\"role\": \"user\", \"content\": big_plain},   # 1: 135 tokens, plain string\n+            {\"role\": \"assistant\", \"content\": \"tail1\"},\n+            {\"role\": \"user\", \"content\": \"tail2\"},\n+            {\"role\": \"assistant\", \"content\": \"tail3\"},\n+            {\"role\": \"user\", \"content\": \"tail4\"},\n+        ]\n+        c.tail_token_budget = 80\n+        head_end = 0\n+        cut = c._find_tail_cut_by_tokens(messages, head_end)\n+        assert len(messages) - cut >= 4, (\n+            f\"Plain string regression: expected ≥4 messages in tail, got {len(messages) - cut}\"\n+        )\n+\n+    def test_image_only_block_contributes_zero_text_chars(self, budget_compressor):\n+        \"\"\"Image-only content blocks (no 'text' key) contribute 0 chars + base overhead.\"\"\"\n+        c = budget_compressor\n+        c.tail_token_budget = 500\n+        image_only = [{\"type\": \"image_url\", \"image_url\": {\"url\": \"https://example.com/x.jpg\"}}]\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"a\" * 4000},\n+            {\"role\": \"user\", \"content\": image_only},   # 0 text chars → 10 tokens overhead\n+            {\"role\": \"assistant\", \"content\": \"ok\"},\n+        ]\n+        head_end = 0\n+        cut = c._find_tail_cut_by_tokens(messages, head_end)\n+        assert isinstance(cut, int)\n+        assert 0 <= cut <= len(messages)\n+\n+    def test_mixed_list_with_bare_strings_does_not_crash(self, budget_compressor):\n+        \"\"\"Content list may contain bare strings (not dicts) — must not raise AttributeError.\"\"\"\n+        c = budget_compressor\n+        c.tail_token_budget = 500\n+        # Bare string item alongside a dict item — normalisation elsewhere allows this.\n+        mixed_content = [\"Hello, world!\", {\"type\": \"text\", \"text\": \"extra text\"}]\n+        messages = [\n+            {\"role\": \"user\", \"content\": mixed_content},\n+            {\"role\": \"assistant\", \"content\": \"ok\"},\n+        ]\n+        head_end = 0\n+        cut = c._find_tail_cut_by_tokens(messages, head_end)\n+        assert isinstance(cut, int)\n+        assert 0 <= cut <= len(messages)\n+\n \n class TestUpdateModelBudgets:\n     \"\"\"Regression: update_model() must recalculate token budgets.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}